diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 58046784..78d3cda4 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -10,7 +10,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use tokio::time::timeout; @@ -173,7 +173,74 @@ struct AgentsLockFile { struct AgentsLockSkill { source: Option, source_type: Option, + source_url: Option, skill_path: Option, + branch: Option, + source_branch: Option, +} + +#[derive(Debug, Clone)] +struct LockRepoInfo { + owner: String, + repo: String, + skill_path: Option, + branch: Option, +} + +fn normalize_optional_branch(branch: Option) -> Option { + branch.and_then(|b| { + let trimmed = b.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn parse_branch_from_source_url(source_url: Option<&str>) -> Option { + let source_url = source_url?; + let source_url = source_url.trim(); + if source_url.is_empty() { + return None; + } + + // 支持 https://github.com/owner/repo/tree//... + if let Some((_, after_tree)) = source_url.split_once("/tree/") { + let branch = after_tree + .split('/') + .next() + .map(str::trim) + .filter(|s| !s.is_empty())?; + return Some(branch.to_string()); + } + + // 支持 URL fragment: ...git#branch + if let Some((_, fragment)) = source_url.split_once('#') { + let branch = fragment + .split('&') + .next() + .map(str::trim) + .filter(|s| !s.is_empty())?; + return Some(branch.to_string()); + } + + // 支持 query: ...?branch=xxx / ?ref=xxx + if let Some((_, query)) = source_url.split_once('?') { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + if matches!(key, "branch" | "ref") { + let branch = value.trim(); + if !branch.is_empty() { + return Some(branch.to_string()); + } + } + } + } + + None } /// 获取 `~/.agents/skills/` 目录(存在时返回) @@ -183,21 +250,35 @@ fn get_agents_skills_dir() -> Option { .filter(|p| p.exists()) } -/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> (owner, repo_name, skill_path) -fn parse_agents_lock() -> HashMap)> { +/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息 +fn parse_agents_lock() -> HashMap { let path = match dirs::home_dir() { Some(h) => h.join(".agents").join(".skill-lock.json"), - None => return HashMap::new(), + None => { + log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); + return HashMap::new(); + } }; let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(_) => return HashMap::new(), + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + log::debug!("未找到 agents lock 文件: {}", path.display()); + } else { + log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e); + } + return HashMap::new(); + } }; let lock: AgentsLockFile = match serde_json::from_str(&content) { Ok(l) => l, - Err(_) => return HashMap::new(), + Err(e) => { + log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e); + return HashMap::new(); + } }; - lock.skills + let parsed: HashMap = lock + .skills .into_iter() .filter_map(|(name, skill)| { let source = skill.source?; @@ -205,12 +286,25 @@ fn parse_agents_lock() -> HashMap)> { return None; } let (owner, repo) = source.split_once('/')?; + let branch = normalize_optional_branch(skill.branch) + .or_else(|| normalize_optional_branch(skill.source_branch)) + .or_else(|| parse_branch_from_source_url(skill.source_url.as_deref())); Some(( name, - (owner.to_string(), repo.to_string(), skill.skill_path), + LockRepoInfo { + owner: owner.to_string(), + repo: repo.to_string(), + skill_path: skill.skill_path, + branch, + }, )) }) - .collect() + .collect(); + log::info!( + "agents lock 文件解析完成,共识别 {} 个 github skill", + parsed.len() + ); + parsed } // ========== SkillService ========== @@ -330,10 +424,13 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; // 使用目录最后一段作为安装名 - let install_name = Path::new(&skill.directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| skill.directory.clone()); + let install_name = Self::sanitize_install_name(&skill.directory).ok_or_else(|| { + anyhow!(format_skill_error( + "INVALID_SKILL_DIRECTORY", + &[("directory", &skill.directory)], + Some("checkZipContent"), + )) + })?; // 检查数据库中是否已有同名 directory 的 skill(来自其他仓库) let existing_skills = db.get_all_installed_skills()?; @@ -1081,6 +1178,32 @@ impl SkillService { } } + /// 校验并规范化安装目录名,拒绝路径穿越和多级路径 + fn sanitize_install_name(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let path = Path::new(trimmed); + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Normal(name)), None) => { + let normalized = name.to_string_lossy().trim().to_string(); + if normalized.is_empty() + || normalized == "." + || normalized == ".." + || normalized.starts_with('.') + { + None + } else { + Some(normalized) + } + } + _ => None, + } + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -1104,7 +1227,7 @@ impl SkillService { let _ = temp_dir.keep(); let mut branches = Vec::new(); - if !repo.branch.is_empty() { + if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") { branches.push(repo.branch.as_str()); } if !branches.contains(&"main") { @@ -1314,6 +1437,10 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; let mut installed = Vec::new(); let existing_skills = db.get_all_installed_skills()?; + let zip_stem = zip_path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()); for skill_dir in skill_dirs { // 解析元数据(提前解析,用于确定安装名) @@ -1336,15 +1463,28 @@ impl SkillService { if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') { // SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名 meta.as_ref() - .and_then(|m| m.name.clone()) - .unwrap_or_else(|| { - zip_path - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()) - }) + .and_then(|m| m.name.as_deref()) + .and_then(Self::sanitize_install_name) + .or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name)) } else { - dir_name + Self::sanitize_install_name(&dir_name) + .or_else(|| { + meta.as_ref() + .and_then(|m| m.name.as_deref()) + .and_then(Self::sanitize_install_name) + }) + .or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name)) + } + }; + let install_name = match install_name { + Some(name) => name, + None => { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "INVALID_SKILL_DIRECTORY", + &[("zip", &zip_path.display().to_string())], + Some("checkZipContent"), + ))); } }; @@ -1536,7 +1676,7 @@ impl SkillService { /// /// 返回 (id, repo_owner, repo_name, repo_branch, readme_url) fn build_repo_info_from_lock( - lock: &HashMap)>, + lock: &HashMap, dir_name: &str, ) -> ( String, @@ -1546,19 +1686,23 @@ fn build_repo_info_from_lock( Option, ) { match lock.get(dir_name) { - Some((owner, repo, skill_path)) => { - let branch = "main".to_string(); + Some(info) => { + let branch = info.branch.clone(); + let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string()); // 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md let fallback = format!("{dir_name}/SKILL.md"); - let doc_path = skill_path.as_deref().unwrap_or(&fallback); + let doc_path = info.skill_path.as_deref().unwrap_or(&fallback); let url = Some(SkillService::build_skill_doc_url( - owner, repo, &branch, doc_path, + &info.owner, + &info.repo, + &url_branch, + doc_path, )); ( - format!("{owner}/{repo}:{dir_name}"), - Some(owner.clone()), - Some(repo.clone()), - Some(branch), + format!("{}/{}:{dir_name}", info.owner, info.repo), + Some(info.owner.clone()), + Some(info.repo.clone()), + branch, url, ) } @@ -1569,7 +1713,7 @@ fn build_repo_info_from_lock( /// 将 lock 文件中发现的仓库保存到 skill_repos(去重) fn save_repos_from_lock( db: &Arc, - lock: &HashMap)>, + lock: &HashMap, directories: impl Iterator>, ) { let existing_repos: HashSet<(String, String)> = db @@ -1581,19 +1725,25 @@ fn save_repos_from_lock( let mut added = HashSet::new(); for dir_name in directories { - if let Some((owner, repo, _)) = lock.get(dir_name.as_ref()) { - let key = (owner.clone(), repo.clone()); + if let Some(info) = lock.get(dir_name.as_ref()) { + let key = (info.owner.clone(), info.repo.clone()); if !existing_repos.contains(&key) && added.insert(key) { let skill_repo = SkillRepo { - owner: owner.clone(), - name: repo.clone(), - branch: "main".to_string(), + owner: info.owner.clone(), + name: info.repo.clone(), + // 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。 + branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()), enabled: true, }; if let Err(e) = db.save_skill_repo(&skill_repo) { - log::warn!("保存 skill 仓库 {owner}/{repo} 失败: {e}"); + log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e); } else { - log::info!("从 agents lock 文件发现并添加仓库: {owner}/{repo}"); + log::info!( + "从 agents lock 文件发现并添加仓库: {}/{} ({})", + info.owner, + info.repo, + skill_repo.branch + ); } } }