Files
CC-Switch/src-tauri/src/services/skill.rs
T
Jason 87b0e3fb85 fix(test): pin zip extraction temp dir instead of hijacking TMPDIR
The two cleanup-guard tests introduced in ff3bc242 set the process-global
TMPDIR to a scratch dir and asserted it ended up empty. serial_test only
serializes marked tests, so any concurrent test creating a tempdir inside
the hijacked window landed in scratch and randomly failed the emptiness
assertion on Ubuntu/macOS CI (Windows ignores TMPDIR).

Add an extract_local_zip_in(zip_path, base_dir) seam that takes the temp
base explicitly; the public function delegates with std::env::temp_dir().
Tests now pass their private scratch dir directly, dropping the TMPDIR
mutation and the serial markers — the race is impossible by construction.
2026-07-29 10:22:30 +08:00

4456 lines
167 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Skills 服务层
//!
//! v3.10.0+ 统一管理架构:
//! - SSOT(单一事实源):`~/.cc-switch/skills/`
//! - 安装时下载到 SSOT,按需同步到各应用目录
//! - 数据库存储安装记录和启用状态
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::timeout;
use crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill};
use crate::config::get_app_config_dir;
use crate::database::Database;
use crate::error::format_skill_error;
// ========== 数据结构 ==========
/// Skill 同步方式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SyncMethod {
/// 自动选择:优先 symlink,失败时回退到 copy
#[default]
Auto,
/// 符号链接(推荐,节省磁盘空间)
Symlink,
/// 文件复制(兼容模式)
Copy,
}
/// Skill 存储位置(SSOT 目录选择)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillStorageLocation {
/// CC Switch 管理目录 (~/.cc-switch/skills/)
#[default]
CcSwitch,
/// Agent Skills 统一标准目录 (~/.agents/skills/)
Unified,
}
/// 可发现的技能(来自仓库)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoverableSkill {
/// 唯一标识: "owner/name:directory"
pub key: String,
/// 显示名称 (从 SKILL.md 解析)
pub name: String,
/// 技能描述
pub description: String,
/// 目录名称 (安装路径的最后一段)
pub directory: String,
/// GitHub README URL
#[serde(rename = "readmeUrl")]
pub readme_url: Option<String>,
/// 仓库所有者
#[serde(rename = "repoOwner")]
pub repo_owner: String,
/// 仓库名称
#[serde(rename = "repoName")]
pub repo_name: String,
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: String,
}
/// 技能对象(兼容旧 API,内部使用 DiscoverableSkill
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
/// 唯一标识: "owner/name:directory" 或 "local:directory"
pub key: String,
/// 显示名称 (从 SKILL.md 解析)
pub name: String,
/// 技能描述
pub description: String,
/// 目录名称 (安装路径的最后一段)
pub directory: String,
/// GitHub README URL
#[serde(rename = "readmeUrl")]
pub readme_url: Option<String>,
/// 是否已安装
pub installed: bool,
/// 仓库所有者
#[serde(rename = "repoOwner")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(rename = "repoName")]
pub repo_name: Option<String>,
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
}
/// 仓库配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRepo {
/// GitHub 用户/组织名
pub owner: String,
/// 仓库名称
pub name: String,
/// 分支 (默认 "main")
pub branch: String,
/// 是否启用
pub enabled: bool,
}
/// 技能安装状态(旧版兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
/// 是否已安装
pub installed: bool,
/// 安装时间
#[serde(rename = "installedAt")]
pub installed_at: DateTime<Utc>,
}
/// 持久化存储结构(仓库配置)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillStore {
/// directory -> 安装状态(旧版兼容,新版不使用)
pub skills: HashMap<String, SkillState>,
/// 仓库列表
pub repos: Vec<SkillRepo>,
}
impl Default for SkillStore {
fn default() -> Self {
SkillStore {
skills: HashMap::new(),
repos: vec![
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
},
SkillRepo {
owner: "ComposioHQ".to_string(),
name: "awesome-claude-skills".to_string(),
branch: "master".to_string(),
enabled: true,
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
},
SkillRepo {
owner: "JimLiu".to_string(),
name: "baoyu-skills".to_string(),
branch: "main".to_string(),
enabled: true,
},
],
}
}
}
/// Skill 卸载结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillUninstallResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
}
/// Skill 更新检测结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillUpdateInfo {
/// Skill ID
pub id: String,
/// Skill 名称
pub name: String,
/// 当前本地哈希
pub current_hash: Option<String>,
/// 远程最新哈希
pub remote_hash: String,
}
/// Skill 存储位置迁移结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationResult {
pub migrated_count: usize,
pub skipped_count: usize,
pub errors: Vec<String>,
}
// ========== skills.sh API 类型 ==========
/// skills.sh API 原始响应
///
/// 注意:API 命名不一致(searchType 是 camelCaseduration_ms 是 snake_case),
/// 因此不能用 rename_all,需要逐字段指定。
#[derive(Debug, Clone, Deserialize)]
struct SkillsShApiResponse {
pub query: String,
#[serde(rename = "searchType")]
#[allow(dead_code)]
pub search_type: String,
pub skills: Vec<SkillsShApiSkill>,
pub count: usize,
#[allow(dead_code)]
pub duration_ms: u64,
}
/// skills.sh API 原始技能条目
#[derive(Debug, Clone, Deserialize)]
struct SkillsShApiSkill {
pub id: String,
#[serde(rename = "skillId")]
pub skill_id: String,
pub name: String,
pub installs: u64,
pub source: String,
}
/// skills.sh 搜索结果(返回给前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsShSearchResult {
pub skills: Vec<SkillsShDiscoverableSkill>,
pub total_count: usize,
pub query: String,
}
/// skills.sh 可安装技能(返回给前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsShDiscoverableSkill {
pub key: String,
pub name: String,
pub directory: String,
pub repo_owner: String,
pub repo_name: String,
pub repo_branch: String,
pub installs: u64,
pub readme_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillBackupEntry {
pub backup_id: String,
pub backup_path: String,
pub created_at: i64,
pub skill: InstalledSkill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SkillBackupMetadata {
skill: InstalledSkill,
backup_created_at: i64,
source_path: String,
}
const SKILL_BACKUP_RETAIN_COUNT: usize = 20;
/// 仓库归档解压上限:条目数与解压后总字节数。
///
/// 归档字节由第三方完全控制(仓库可经 deeplink 添加,且 branch 可把下载落点
/// 改写到攻击者自传的 release asset),没有上限时一个几 MB 的压缩炸弹就能塞满磁盘。
/// 取值对齐 `webdav_sync/archive.rs` 里同款保护的量级。
const MAX_ARCHIVE_ENTRIES: usize = 10_000;
const MAX_ARCHIVE_TOTAL_BYTES: u64 = 512 * 1024 * 1024;
/// symlink 目标就是一条路径,几十字节就够;给到 4 KiB 是宽松上限。
/// 必须有这个上限:zip 2.4.2 的 `make_reader` 不按声明的 uncompressed_size
/// 截断读取,所以一个打了 symlink 标志、deflate 流却能膨胀到数 GB 的条目,
/// 会被 `read_to_string` 整个读进内存。
const MAX_SYMLINK_TARGET_BYTES: u64 = 4 * 1024;
/// 物化一个目录按一个目录块计费。空目录不写内容字节,但照样吃 inode 和磁盘块,
/// 不计费就等于允许无限量地造目录。
const DIRECTORY_BUDGET_COST: u64 = 4096;
/// 压缩体上限。解压预算只有在 ZipArchive 建起来之后才生效,而那时整个响应体
/// 已经在内存里了,所以下载这一步需要自己的上限。技能仓库是 Markdown,
/// 128 MiB 的压缩包已经远超正常规模。
const MAX_ARCHIVE_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024;
/// 技能元数据 (从 SKILL.md 解析)
#[derive(Debug, Clone, Deserialize)]
pub struct SkillMetadata {
pub name: Option<String>,
pub description: Option<String>,
}
/// 导入已有 Skill 时,前端显式提交的启用应用选择
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportSkillSelection {
pub directory: String,
#[serde(default)]
pub apps: SkillApps,
}
#[derive(Debug, Clone, Deserialize)]
struct LegacySkillMigrationRow {
directory: String,
app_type: String,
}
// ========== ~/.agents/ lock 文件解析 ==========
/// `~/.agents/.skill-lock.json` 文件结构
#[derive(Deserialize)]
struct AgentsLockFile {
skills: HashMap<String, AgentsLockSkill>,
}
/// lock 文件中单个 skill 的信息
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentsLockSkill {
source: Option<String>,
source_type: Option<String>,
source_url: Option<String>,
skill_path: Option<String>,
branch: Option<String>,
source_branch: Option<String>,
}
#[derive(Debug, Clone)]
struct LockRepoInfo {
owner: String,
repo: String,
skill_path: Option<String>,
branch: Option<String>,
}
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
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<String> {
let source_url = source_url?;
let source_url = source_url.trim();
if source_url.is_empty() {
return None;
}
// 支持 https://github.com/owner/repo/tree/<branch>/...
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/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
let dir = crate::config::get_home_dir().join(".agents").join("skills");
dir.exists().then_some(dir)
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = crate::config::get_home_dir()
.join(".agents")
.join(".skill-lock.json");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
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(e) => {
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
return HashMap::new();
}
};
let parsed: HashMap<String, LockRepoInfo> = lock
.skills
.into_iter()
.filter_map(|(name, skill)| {
let source = skill.source?;
if skill.source_type.as_deref() != Some("github") {
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,
LockRepoInfo {
owner: owner.to_string(),
repo: repo.to_string(),
skill_path: skill.skill_path,
branch,
},
))
})
.collect();
log::info!(
"agents lock 文件解析完成,共识别 {} 个 github skill",
parsed.len()
);
parsed
}
// ========== SkillService ==========
pub struct SkillService;
impl Default for SkillService {
fn default() -> Self {
Self::new()
}
}
impl SkillService {
pub fn new() -> Self {
Self
}
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
///
/// 坐标不合法时返回 None:这个值会存进 `readme_url`,前端「查看文档」用
/// `openExternal` 直接打开,恶意 branch 能把它指到 github.com 上的任意路径。
fn build_skill_doc_url(
owner: &str,
repo: &str,
branch: &str,
doc_path: &str,
) -> Option<String> {
if Self::validate_repo_ref(owner, repo, branch).is_err() {
log::warn!("跳过非法仓库坐标的文档链接: {owner}/{repo}@{branch}");
return None;
}
Some(format!(
"https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}"
))
}
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
fn extract_doc_path_from_url(url: &str) -> Option<String> {
let marker = if url.contains("/blob/") {
"/blob/"
} else if url.contains("/tree/") {
"/tree/"
} else {
return None;
};
let (_, tail) = url.split_once(marker)?;
let (_, path) = tail.split_once('/')?;
if path.is_empty() {
return None;
}
Some(path.to_string())
}
// ========== 路径管理 ==========
/// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/
pub fn get_ssot_dir() -> Result<PathBuf> {
let location = crate::settings::get_skill_storage_location();
let dir = match location {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&dir)?;
Ok(dir)
}
/// 获取 Skill 卸载备份目录(~/.cc-switch/skill-backups/
fn get_backup_dir() -> Result<PathBuf> {
let dir = get_app_config_dir().join("skill-backups");
fs::create_dir_all(&dir)?;
Ok(dir)
}
/// 获取应用的 skills 目录
pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {
// 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录
match app {
AppType::Claude => {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::ClaudeDesktop => {}
AppType::Codex => {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::Gemini => {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::GrokBuild => {
if let Some(custom) = crate::settings::get_grok_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::OpenClaw => {
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::Hermes => {
if let Some(custom) = crate::settings::get_hermes_override_dir() {
return Ok(custom.join("skills"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置。
// 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir()
// 走 Known Folder API,测试无法隔离真实用户目录。
let home = crate::config::get_home_dir();
Ok(match app {
AppType::Claude => home.join(".claude").join("skills"),
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::GrokBuild => home.join(".grok").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
})
}
// ========== 统一管理方法 ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed(db: &Arc<Database>) -> Result<Vec<InstalledSkill>> {
let skills = db.get_all_installed_skills()?;
Ok(skills.into_values().collect())
}
/// 安装 Skill
///
/// 流程:
/// 1. 下载到 SSOT 目录
/// 2. 保存到数据库
/// 3. 同步到启用的应用目录
pub async fn install(
&self,
db: &Arc<Database>,
skill: &DiscoverableSkill,
current_app: &AppType,
) -> Result<InstalledSkill> {
let ssot_dir = Self::get_ssot_dir()?;
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
let install_name = source_rel
.file_name()
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
.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()?;
for existing in existing_skills.values() {
if existing.directory.eq_ignore_ascii_case(&install_name) {
// 检查是否来自同一仓库
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
if same_repo {
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
let mut updated = existing.clone();
updated.apps.set_enabled_for(current_app, true);
db.save_skill(&updated)?;
Self::sync_to_app_dir(&updated.directory, current_app)?;
log::info!(
"Skill {} 已存在,更新 {:?} 启用状态",
updated.name,
current_app
);
return Ok(updated);
} else {
// 不同仓库的同名 skill,报错
return Err(anyhow!(format_skill_error(
"SKILL_DIRECTORY_CONFLICT",
&[
("directory", &install_name),
(
"existing_repo",
&format!(
"{}/{}",
existing.repo_owner.as_deref().unwrap_or("unknown"),
existing.repo_name.as_deref().unwrap_or("unknown")
)
),
(
"new_repo",
&format!("{}/{}", skill.repo_owner, skill.repo_name)
),
],
Some("uninstallFirst"),
)));
}
}
}
let dest = ssot_dir.join(&install_name);
let mut repo_branch = skill.repo_branch.clone();
// 如果已存在则跳过下载
if !dest.exists() {
let repo = SkillRepo {
owner: skill.repo_owner.clone(),
name: skill.repo_name.clone(),
branch: skill.repo_branch.clone(),
enabled: true,
};
// 下载仓库
let (temp_guard, used_branch) = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let temp_dir = temp_guard.path();
repo_branch = used_branch;
// 复制到 SSOT
let source =
Self::resolve_skill_source_dir(temp_dir, &skill.directory).ok_or_else(|| {
let missing = temp_dir.join(&source_rel).display().to_string();
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &missing)],
Some("checkRepoUrl"),
))
})?;
let canonical_temp = temp_dir
.canonicalize()
.unwrap_or_else(|_| temp_dir.to_path_buf());
let canonical_source = source.canonicalize().map_err(|_| {
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
))
})?;
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
)));
}
Self::copy_dir_recursive(&canonical_source, &dest)?;
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
if repo_branch != skill.repo_branch {
log::info!(
"Skill {}/{} 分支自动回退: {} -> {}",
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
repo_branch
);
}
}
let doc_path = skill
.readme_url
.as_deref()
.and_then(Self::extract_doc_path_from_url)
.map(|path| {
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
path
} else {
format!("{}/SKILL.md", path.trim_end_matches('/'))
}
})
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
let readme_url =
Self::build_skill_doc_url(&skill.repo_owner, &skill.repo_name, &repo_branch, &doc_path);
// 创建 InstalledSkill 记录
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| {
log::warn!("Failed to compute content hash for {}: {e}", install_name);
None
});
let installed_skill = InstalledSkill {
id: skill.key.clone(),
name: skill.name.clone(),
description: if skill.description.is_empty() {
None
} else {
Some(skill.description.clone())
},
directory: install_name.clone(),
repo_owner: Some(skill.repo_owner.clone()),
repo_name: Some(skill.repo_name.clone()),
repo_branch: Some(repo_branch),
readme_url,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&installed_skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
log::info!(
"Skill {} 安装成功,已启用 {:?}",
installed_skill.name,
current_app
);
Ok(installed_skill)
}
/// 卸载 Skill
///
/// 流程:
/// 1. 从所有应用目录删除
/// 2. 从 SSOT 删除
/// 3. 从数据库删除
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
// 获取 skill 信息
let skill = db
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// DB 行可能被同步导入污染(远端快照 raw SQL 直接灌库,绕过安装期校验),
// 也可能是 v3.11.0 引入 sanitize_install_name 之前留下的存量脏值
// (当年扫描不过滤点开头目录,`.github/SKILL.md` 会存成 `.github`)。
//
// 守卫失败时**跳过全部文件系统操作、但仍删除 DB 行**`db.delete_skill`
// 全项目只有这一处调用且未暴露为命令,若在此直接返回 Err,用户就再也无法
// 从界面删掉这条记录,只能手改 SQLite。安全目标是「不碰危险路径」,
// 不是「把用户锁在坏状态里」。
let backup_path = match Self::require_valid_directory(&skill.directory) {
Ok(directory) => {
let backup_path = Self::create_uninstall_backup(&skill)?
.map(|path| path.to_string_lossy().to_string());
// 从所有应用目录删除
for app in AppType::all() {
let _ = Self::remove_from_app(&directory, &app);
}
// 从 SSOT 删除
let ssot_dir = Self::get_ssot_dir()?;
let skill_path = ssot_dir.join(&directory);
if skill_path.exists() {
fs::remove_dir_all(&skill_path)?;
}
backup_path
}
Err(err) => {
log::warn!(
"Skill {id} 的 directory 非法({:?}),跳过文件清理,仅删除数据库记录: {err}",
skill.directory
);
None
}
};
// 从数据库删除
db.delete_skill(id)?;
log::info!(
"Skill {} 卸载成功{}",
skill.name,
backup_path
.as_deref()
.map(|path| format!(", backup: {path}"))
.unwrap_or_default()
);
Ok(SkillUninstallResult { backup_path })
}
// ========== 更新检测 ==========
/// 计算目录内容的 SHA-256 哈希
///
/// 递归遍历目录下所有非隐藏文件,按相对路径字典序排列,
/// 将 "相对路径\0内容\0" 逐文件 feed 给同一个 hasher。
pub fn compute_dir_hash(dir: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut files: Vec<PathBuf> = Vec::new();
Self::collect_files_for_hash(dir, dir, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for file_path in &files {
let relative = file_path.strip_prefix(dir).unwrap_or(file_path);
let rel_str = relative.to_string_lossy().replace('\\', "/");
hasher.update(rel_str.as_bytes());
hasher.update(b"\0");
let content = fs::read(file_path)
.with_context(|| format!("读取文件失败: {}", file_path.display()))?;
hasher.update(&content);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
/// 递归收集目录下所有非隐藏文件
#[allow(clippy::only_used_in_recursion)]
fn collect_files_for_hash(base: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(current)
.with_context(|| format!("读取目录失败: {}", current.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
Self::collect_files_for_hash(base, &path, files)?;
} else {
files.push(path);
}
}
Ok(())
}
/// 检查所有已安装 Skill 的更新
///
/// 仅检查有 repo_owner 的 Skill(本地 Skill 跳过),
/// 按仓库分组下载,避免重复下载同一仓库。
pub async fn check_updates(&self, db: &Arc<Database>) -> Result<Vec<SkillUpdateInfo>> {
let skills = db.get_all_installed_skills()?;
let mut updates = Vec::new();
// 按 (owner, name, branch) 分组
let mut repo_groups: HashMap<(String, String, String), Vec<InstalledSkill>> =
HashMap::new();
for skill in skills.into_values() {
let (owner, name, branch) =
match (&skill.repo_owner, &skill.repo_name, &skill.repo_branch) {
(Some(o), Some(n), Some(b)) => (o.clone(), n.clone(), b.clone()),
(Some(o), Some(n), None) => (o.clone(), n.clone(), "main".to_string()),
_ => continue,
};
repo_groups
.entry((owner, name, branch))
.or_default()
.push(skill);
}
let ssot_dir = Self::get_ssot_dir()?;
for ((owner, name, branch), group_skills) in &repo_groups {
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: branch.clone(),
enabled: true,
};
// 下载仓库 ZIP
let (temp_guard, _used_branch) = match timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
log::warn!("检查更新时下载 {}/{} 失败: {e}", owner, name);
continue;
}
Err(_) => {
log::warn!("检查更新时下载 {}/{} 超时", owner, name);
continue;
}
};
let temp_dir = temp_guard.path();
// 扫描仓库中的所有 Skill 目录
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
let _ = self.scan_dir_recursive(temp_dir, temp_dir, &repo, &mut remote_skills);
for skill in group_skills {
// 在远程仓库中找到匹配的 Skill 目录
let remote_match = remote_skills.iter().find(|rs| {
// 匹配方式:安装名称的最后一段
let remote_install_name =
rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
remote_install_name.eq_ignore_ascii_case(&skill.directory)
});
let remote_skill_dir = match remote_match {
Some(rs) => match Self::resolve_skill_source_dir(temp_dir, &rs.directory) {
Some(path) => path,
None => continue,
},
None => continue,
};
let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) {
Ok(h) => h,
Err(e) => {
log::warn!("计算远程哈希失败 {}: {e}", skill.id);
continue;
}
};
// 本地哈希:优先数据库,否则实时计算
let local_hash = match &skill.content_hash {
Some(h) => Some(h.clone()),
// 脏 directory 会让 compute_dir_hash 递归遍历任意目录,
// 且哈希结果经「有无更新」的界面状态泄露少量信息。
None => match Self::require_valid_directory(&skill.directory) {
Err(err) => {
log::warn!("跳过非法 directory 的哈希计算: {err}");
None
}
Ok(directory) => {
let local_dir = ssot_dir.join(&directory);
if local_dir.exists() {
match Self::compute_dir_hash(&local_dir) {
Ok(h) => {
let _ = db.update_skill_hash(&skill.id, &h, 0);
Some(h)
}
Err(_) => None,
}
} else {
None
}
}
},
};
if local_hash.as_deref() != Some(&remote_hash) {
updates.push(SkillUpdateInfo {
id: skill.id.clone(),
name: skill.name.clone(),
current_hash: local_hash,
remote_hash,
});
}
}
}
Ok(updates)
}
/// 更新单个 Skill(重新下载并替换本地文件)
pub async fn update_skill(&self, db: &Arc<Database>, skill_id: &str) -> Result<InstalledSkill> {
let skill = db
.get_installed_skill(skill_id)?
.ok_or_else(|| anyhow!("Skill not found: {skill_id}"))?;
// 本函数后续三种危险操作都用 directory 拼路径:备份源(把任意目录复制进
// 备份区并在界面列出)、remove_dir_all(删任意目录)、copy_dir_recursive
// (把远端仓库内容写到任意路径)。校验必须在这三者之前。
Self::require_valid_directory(&skill.directory)?;
let (owner, name, branch) = match (&skill.repo_owner, &skill.repo_name) {
(Some(o), Some(n)) => (
o.clone(),
n.clone(),
skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
),
_ => return Err(anyhow!("Cannot update local skill: {skill_id}")),
};
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: branch.clone(),
enabled: true,
};
let ssot_dir = Self::get_ssot_dir()?;
// 下载仓库
let (temp_guard, used_branch) = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[("owner", &owner), ("name", &name), ("timeout", "60")],
Some("checkNetwork"),
))
})??;
let temp_dir = temp_guard.path();
// 在解压的仓库中查找 Skill 源目录
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
let _ = self.scan_dir_recursive(temp_dir, temp_dir, &repo, &mut remote_skills);
let remote_match = remote_skills
.iter()
.find(|rs| {
let remote_install_name = rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
remote_install_name.eq_ignore_ascii_case(&skill.directory)
})
.ok_or_else(|| {
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &skill.directory)],
Some("checkRepoUrl"),
))
})?;
let source =
Self::resolve_skill_source_dir(temp_dir, &remote_match.directory).ok_or_else(|| {
let missing = temp_dir.join(&remote_match.directory).display().to_string();
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &missing)],
Some("checkRepoUrl"),
))
})?;
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
// 删除旧 SSOT 目录并复制新文件
let dest = ssot_dir.join(&skill.directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
Self::copy_dir_recursive(&source, &dest)?;
// 计算新哈希 + 解析新元数据
let new_hash = Self::compute_dir_hash(&dest).ok();
let skill_md = dest.join("SKILL.md");
let (new_name, new_description) = Self::read_skill_name_desc(&skill_md, &skill.directory);
// 更新 readme_url
let doc_path = skill
.readme_url
.as_deref()
.and_then(Self::extract_doc_path_from_url)
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
let readme_url = Self::build_skill_doc_url(&owner, &name, &used_branch, &doc_path);
let updated_skill = InstalledSkill {
id: skill.id.clone(),
name: new_name,
description: new_description,
directory: skill.directory.clone(),
repo_owner: skill.repo_owner.clone(),
repo_name: skill.repo_name.clone(),
repo_branch: Some(used_branch),
readme_url,
apps: skill.apps.clone(),
installed_at: skill.installed_at,
content_hash: new_hash,
updated_at: chrono::Utc::now().timestamp(),
};
db.save_skill(&updated_skill)?;
// 同步到所有已启用的应用目录
for app in updated_skill.apps.enabled_apps() {
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
}
}
log::info!("Skill {} 更新成功", updated_skill.name);
Ok(updated_skill)
}
/// 为缺少 content_hash 的已安装 Skill 补算哈希
pub fn backfill_content_hashes(db: &Arc<Database>) -> Result<usize> {
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
let mut count = 0;
for skill in skills.values() {
if skill.content_hash.is_some() {
continue;
}
let Ok(directory) = Self::require_valid_directory(&skill.directory) else {
log::warn!("跳过非法 directory 的哈希回填: {:?}", skill.directory);
continue;
};
let skill_dir = ssot_dir.join(&directory);
if !skill_dir.exists() {
continue;
}
match Self::compute_dir_hash(&skill_dir) {
Ok(hash) => {
let _ = db.update_skill_hash(&skill.id, &hash, 0);
count += 1;
}
Err(e) => {
log::warn!("补算哈希失败 {}: {e}", skill.id);
}
}
}
if count > 0 {
log::info!("已为 {count} 个 Skill 补算内容哈希");
}
Ok(count)
}
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
///
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
pub fn migrate_storage(
db: &Arc<Database>,
target: SkillStorageLocation,
) -> Result<MigrationResult> {
let current = crate::settings::get_skill_storage_location();
if current == target {
return Ok(MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
});
}
// 1. 解析旧目录和新目录(不改设置)
let old_dir = Self::get_ssot_dir()?;
let new_dir = match target {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&new_dir)?;
// 2. 逐个移动 skill 目录
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
};
for skill in skills.values() {
// 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。
// 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill,
// 不要整体中断——用户只是在切换存储位置。
let directory = match Self::require_valid_directory(&skill.directory) {
Ok(directory) => directory,
Err(err) => {
result
.errors
.push(format!("{}: {err}", skill.directory.escape_debug()));
continue;
}
};
let src = old_dir.join(&directory);
let dst = new_dir.join(&directory);
if !src.exists() {
result.skipped_count += 1;
continue;
}
if dst.exists() {
result.skipped_count += 1;
continue;
}
// 优先 rename(同文件系统原子操作),失败则 copy+delete
match fs::rename(&src, &dst) {
Ok(()) => result.migrated_count += 1,
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
Ok(()) => {
let _ = fs::remove_dir_all(&src);
result.migrated_count += 1;
}
Err(e) => {
result.errors.push(format!("{}: {e}", skill.directory));
}
},
}
}
// 3. 文件移动完成后才持久化设置
crate::settings::set_skill_storage_location(target)?;
// 4. 刷新所有应用目录的 symlink(指向新 SSOT
for app in AppType::all() {
let _ = Self::sync_to_app(db, &app);
}
log::info!(
"Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误",
result.migrated_count,
result.skipped_count,
result.errors.len()
);
Ok(result)
}
pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {
let backup_dir = Self::get_backup_dir()?;
let mut entries = Vec::new();
for entry in fs::read_dir(&backup_dir)? {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
log::warn!("读取 Skill 备份目录项失败: {err}");
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
match Self::read_backup_metadata(&path) {
Ok(metadata) => entries.push(SkillBackupEntry {
backup_id: entry.file_name().to_string_lossy().to_string(),
backup_path: path.to_string_lossy().to_string(),
created_at: metadata.backup_created_at,
skill: metadata.skill,
}),
Err(err) => {
log::warn!("解析 Skill 备份失败 {}: {err:#}", path.display());
}
}
}
entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
Ok(entries)
}
pub fn delete_backup(backup_id: &str) -> Result<()> {
let backup_path = Self::backup_path_for_id(backup_id)?;
let metadata = fs::symlink_metadata(&backup_path)
.with_context(|| format!("failed to access {}", backup_path.display()))?;
if !metadata.is_dir() {
return Err(anyhow!(
"Skill backup is not a directory: {}",
backup_path.display()
));
}
fs::remove_dir_all(&backup_path)
.with_context(|| format!("failed to delete {}", backup_path.display()))?;
log::info!("Skill 备份已删除: {}", backup_path.display());
Ok(())
}
pub fn restore_from_backup(
db: &Arc<Database>,
backup_id: &str,
current_app: &AppType,
) -> Result<InstalledSkill> {
let backup_path = Self::backup_path_for_id(backup_id)?;
let metadata = Self::read_backup_metadata(&backup_path)?;
let backup_skill_dir = backup_path.join("skill");
if !backup_skill_dir.join("SKILL.md").exists() {
return Err(anyhow!(
"Skill backup is invalid or missing SKILL.md: {}",
backup_path.display()
));
}
let existing_skills = db.get_all_installed_skills()?;
if existing_skills.contains_key(&metadata.skill.id)
|| existing_skills.values().any(|skill| {
skill
.directory
.eq_ignore_ascii_case(&metadata.skill.directory)
})
{
return Err(anyhow!(
"Skill already exists, please uninstall the current one first: {}",
metadata.skill.directory
));
}
// meta.json 是文件内容(可能来自手工放置或不可信备份),directory 此前
// 未经任何校验就直接 join——可穿越出 SSOT 目录写任意位置。必须先校验。
let directory = Self::require_valid_directory(&metadata.skill.directory)?;
let ssot_dir = Self::get_ssot_dir()?;
let restore_path = ssot_dir.join(&directory);
if restore_path.exists() || Self::is_symlink(&restore_path) {
return Err(anyhow!(
"Restore target already exists: {}",
restore_path.display()
));
}
let mut restored_skill = metadata.skill;
restored_skill.directory = directory;
restored_skill.installed_at = Utc::now().timestamp();
restored_skill.apps = SkillApps::only(current_app);
restored_skill.updated_at = 0;
Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;
// 重新计算内容哈希
restored_skill.content_hash = Self::compute_dir_hash(&restore_path).ok();
if let Err(err) = db.save_skill(&restored_skill) {
let _ = fs::remove_dir_all(&restore_path);
return Err(err.into());
}
if !restored_skill.apps.is_empty() {
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
let _ = db.delete_skill(&restored_skill.id);
let _ = fs::remove_dir_all(&restore_path);
return Err(err);
}
}
log::info!(
"Skill {} 已从备份恢复到 {}",
restored_skill.name,
restore_path.display()
);
Ok(restored_skill)
}
/// 切换应用启用状态
///
/// 启用:复制到应用目录
/// 禁用:从应用目录删除
pub fn toggle_app(db: &Arc<Database>, id: &str, app: &AppType, enabled: bool) -> Result<()> {
// 获取当前 skill
let mut skill = db
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 更新状态
skill.apps.set_enabled_for(app, enabled);
// 同步文件
if enabled {
Self::sync_to_app_dir(&skill.directory, app)?;
} else {
Self::remove_from_app(&skill.directory, app)?;
}
// 更新数据库
db.update_skill_apps(id, &skill.apps)?;
log::info!("Skill {} 的 {:?} 状态已更新为 {}", skill.name, app, enabled);
Ok(())
}
/// 扫描未管理的 Skills
///
/// 扫描各应用目录,找出未被 CC Switch 管理的 Skills
pub fn scan_unmanaged(db: &Arc<Database>) -> Result<Vec<UnmanagedSkill>> {
let managed_skills = db.get_all_installed_skills()?;
let managed_dirs: HashSet<String> = managed_skills
.values()
.map(|s| s.directory.clone())
.collect();
// 收集所有待扫描的目录及其来源标签
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
scan_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
scan_sources.push((agents_dir, "agents".to_string()));
}
if let Ok(ssot_dir) = Self::get_ssot_dir() {
scan_sources.push((ssot_dir, "cc-switch".to_string()));
}
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for (scan_dir, label) in &scan_sources {
let entries = match fs::read_dir(scan_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
unmanaged
.entry(dir_name.clone())
.and_modify(|s| s.found_in.push(label.clone()))
.or_insert(UnmanagedSkill {
directory: dir_name,
name,
description,
found_in: vec![label.clone()],
path: path.display().to_string(),
});
}
}
Ok(unmanaged.into_values().collect())
}
/// 从应用目录导入 Skills
///
/// 将未管理的 Skills 导入到 CC Switch 统一管理
pub fn import_from_apps(
db: &Arc<Database>,
imports: Vec<ImportSkillSelection>,
) -> Result<Vec<InstalledSkill>> {
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(
db,
&agents_lock,
imports.iter().map(|selection| selection.directory.as_str()),
);
// 收集所有候选搜索目录
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
search_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
search_sources.push((agents_dir, "agents".to_string()));
}
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
for selection in imports {
// selection.directory 由前端 IPC 直接传入、此前全程无校验,而它既被
// 用来探测源目录、又作为 copy_dir_recursive 的目标、最后还原样入库。
// 在入口处拒掉,同时切断「脏值 sink」和「脏值来源」两条线。
let dir_name = match Self::require_valid_directory(&selection.directory) {
Ok(dir_name) => dir_name,
Err(err) => {
log::warn!("跳过导入:{err}");
continue;
}
};
// 在所有候选目录中查找
let mut source_path: Option<PathBuf> = None;
for (base, label) in &search_sources {
let skill_path = base.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
}
log::debug!("Skill '{dir_name}' found in source '{label}'");
}
}
let source = match source_path {
Some(p) => p,
None => continue,
};
if !source.join("SKILL.md").exists() {
log::warn!(
"Skip importing '{}' because source '{}' has no SKILL.md",
dir_name,
source.display()
);
continue;
}
// 复制到 SSOT
let dest = ssot_dir.join(&dir_name);
if !dest.exists() {
Self::copy_dir_recursive(&source, &dest)?;
}
// 解析元数据
let skill_md = dest.join("SKILL.md");
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
let apps = selection.apps;
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &dir_name);
// 计算内容哈希
let ssot_skill_dir = ssot_dir.join(&dir_name);
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
// 创建记录
let skill = InstalledSkill {
id,
name,
description,
directory: dir_name,
repo_owner,
repo_name,
repo_branch,
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
imported.push(skill);
}
log::info!("成功导入 {} 个 Skills", imported.len());
Ok(imported)
}
// ========== 文件同步方法 ==========
/// 创建符号链接(跨平台)
///
/// - Unix: 使用 std::os::unix::fs::symlink
/// - Windows: 使用 std::os::windows::fs::symlink_dir
#[cfg(unix)]
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
std::os::unix::fs::symlink(src, dest)
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
}
#[cfg(windows)]
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
std::os::windows::fs::symlink_dir(src, dest)
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
}
/// 检查路径是否为符号链接
fn is_symlink(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
/// 获取当前同步方式配置
fn get_sync_method() -> SyncMethod {
crate::settings::get_skill_sync_method()
}
/// 同步 Skill 到应用目录(使用 symlink 或 copy
///
/// 根据配置和平台选择最佳同步方式:
/// - Auto: 优先尝试 symlink,失败时回退到 copy
/// - Symlink: 仅使用 symlink
/// - Copy: 仅使用文件复制
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。
let directory = Self::require_valid_directory(directory)?;
let ssot_dir = Self::get_ssot_dir()?;
let source = ssot_dir.join(&directory);
Self::validate_sync_source_dir(&source, &directory)?;
let app_dir = Self::get_app_skills_dir(app)?;
fs::create_dir_all(&app_dir)?;
let dest = app_dir.join(&directory);
let sync_method = Self::get_sync_method();
match sync_method {
SyncMethod::Auto => {
if dest.exists() && !Self::is_symlink(&dest) {
Self::replace_dest_with_copy(&source, &dest, &directory)?;
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
return Ok(());
}
if Self::is_symlink(&dest) {
Self::remove_path(&dest)?;
}
// 优先尝试 symlink
match Self::create_symlink(&source, &dest) {
Ok(()) => {
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
return Ok(());
}
Err(err) => {
log::warn!(
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
source.display(),
dest.display()
);
}
}
// Fallback 到 copy
Self::replace_dest_with_copy(&source, &dest, &directory)?;
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
}
SyncMethod::Symlink => {
if dest.exists() || Self::is_symlink(&dest) {
Self::remove_path(&dest)?;
}
Self::create_symlink(&source, &dest)?;
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
}
SyncMethod::Copy => {
Self::replace_dest_with_copy(&source, &dest, &directory)?;
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
}
}
Ok(())
}
/// 复制 Skill 到应用目录(保留用于向后兼容)
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
Self::sync_to_app_dir(directory, app)
}
/// 删除路径(支持 symlink 和真实目录)
fn remove_path(path: &Path) -> Result<()> {
if Self::is_symlink(path) {
// 符号链接:仅删除链接本身,不影响源文件
#[cfg(unix)]
fs::remove_file(path)?;
#[cfg(windows)]
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
} else if path.is_dir() {
// 真实目录:递归删除
fs::remove_dir_all(path)?;
} else if path.exists() {
// 普通文件
fs::remove_file(path)?;
}
Ok(())
}
fn validate_sync_source_dir(source: &Path, directory: &str) -> Result<()> {
if !source.is_dir() {
return Err(anyhow!("Skill 不存在于 SSOT: {directory}"));
}
let manifest = source.join("SKILL.md");
if !manifest.is_file() {
return Err(anyhow!(
"Skill 源目录缺少 SKILL.md,拒绝同步以避免覆盖目标目录: {}",
source.display()
));
}
Ok(())
}
fn replace_dest_with_copy(source: &Path, dest: &Path, directory: &str) -> Result<()> {
Self::validate_sync_source_dir(source, directory)?;
let parent = dest
.parent()
.ok_or_else(|| anyhow!("Invalid skill destination: {}", dest.display()))?;
fs::create_dir_all(parent)?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let tmp_name = Self::sanitize_backup_segment(directory);
let tmp = parent.join(format!(".{tmp_name}.tmp-{}-{nonce}", std::process::id()));
if tmp.exists() || Self::is_symlink(&tmp) {
Self::remove_path(&tmp)?;
}
let copy_result = Self::copy_dir_recursive(source, &tmp);
if let Err(err) = copy_result {
let _ = Self::remove_path(&tmp);
return Err(err);
}
if dest.exists() || Self::is_symlink(dest) {
Self::remove_path(dest)?;
}
fs::rename(&tmp, dest).with_context(|| {
let _ = Self::remove_path(&tmp);
format!(
"替换 Skill 目录失败: {} -> {}",
tmp.display(),
dest.display()
)
})?;
Ok(())
}
/// 判断路径是否为指向 SSOT 目录内的符号链接。
fn is_symlink_to_ssot(path: &Path, ssot_dir: &Path) -> bool {
if !Self::is_symlink(path) {
return false;
}
let Ok(target) = fs::read_link(path) else {
return false;
};
if target.is_absolute() && target.starts_with(ssot_dir) {
return true;
}
let resolved = path
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target.clone());
let canonical_ssot = ssot_dir
.canonicalize()
.unwrap_or_else(|_| ssot_dir.to_path_buf());
let canonical_target = resolved.canonicalize().unwrap_or(resolved);
canonical_target.starts_with(&canonical_ssot)
}
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),
// 这里执行的是删除操作,join 前必须校验,防止任意目录删除。
let directory = Self::require_valid_directory(directory)?;
let app_dir = Self::get_app_skills_dir(app)?;
let skill_path = app_dir.join(&directory);
if skill_path.exists() || Self::is_symlink(&skill_path) {
Self::remove_path(&skill_path)?;
log::debug!("Skill {directory} 已从 {app:?} 删除");
}
Ok(())
}
/// 同步所有已启用的 Skills 到指定应用
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
let app_dir = Self::get_app_skills_dir(app)?;
let indexed_skills: HashMap<String, &InstalledSkill> = skills
.values()
.map(|skill| (skill.directory.to_lowercase(), skill))
.collect();
if app_dir.exists() {
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
let path = entry.path();
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
if let Some(skill) = indexed_skills.get(&dir_name.to_lowercase()) {
if !skill.apps.is_enabled_for(app) {
Self::remove_path(&path)?;
}
continue;
}
if Self::is_symlink_to_ssot(&path, &ssot_dir) {
Self::remove_path(&path)?;
}
}
}
for skill in skills.values() {
if skill.apps.is_enabled_for(app) {
// 逐条容错而非 `?` 传播:本函数在切换供应商时被调用,一条脏
// directory(存量点开头目录、或同步导入灌进来的行)不得让整个
// 应用的 skill 同步全部失效。
if let Err(err) = Self::sync_to_app_dir(&skill.directory, app) {
log::warn!(
"同步 skill {} 到 {app:?} 失败,跳过该条: {err}",
skill.directory
);
}
}
}
Ok(())
}
// ========== 发现功能(保留原有逻辑)==========
/// 列出所有可发现的技能(从仓库获取)
pub async fn discover_available(
&self,
repos: Vec<SkillRepo>,
) -> Result<Vec<DiscoverableSkill>> {
let mut skills = Vec::new();
// 仅使用启用的仓库
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
let fetch_tasks = enabled_repos
.iter()
.map(|repo| self.fetch_repo_skills(repo));
let results: Vec<Result<Vec<DiscoverableSkill>>> =
futures::future::join_all(fetch_tasks).await;
for (repo, result) in enabled_repos.into_iter().zip(results) {
match result {
Ok(repo_skills) => skills.extend(repo_skills),
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
}
}
// 去重并排序
Self::deduplicate_discoverable_skills(&mut skills);
skills.sort_by_key(|skill| skill.name.to_lowercase());
Ok(skills)
}
/// 列出所有技能(兼容旧 API)
pub async fn list_skills(
&self,
repos: Vec<SkillRepo>,
db: &Arc<Database>,
) -> Result<Vec<Skill>> {
// 获取可发现的技能
let discoverable = self.discover_available(repos).await?;
// 获取已安装的技能
let installed = db.get_all_installed_skills()?;
let installed_dirs: HashSet<String> =
installed.values().map(|s| s.directory.clone()).collect();
// 转换为 Skill 格式
let mut skills: Vec<Skill> = discoverable
.into_iter()
.map(|d| {
let install_name = Path::new(&d.directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| d.directory.clone());
Skill {
key: d.key,
name: d.name,
description: d.description,
directory: d.directory,
readme_url: d.readme_url,
installed: installed_dirs.contains(&install_name),
repo_owner: Some(d.repo_owner),
repo_name: Some(d.repo_name),
repo_branch: Some(d.repo_branch),
}
})
.collect();
// 添加本地已安装但不在仓库中的技能
for skill in installed.values() {
let already_in_list = skills.iter().any(|s| {
let s_install_name = Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
s_install_name == skill.directory
});
if !already_in_list {
skills.push(Skill {
key: skill.id.clone(),
name: skill.name.clone(),
description: skill.description.clone().unwrap_or_default(),
directory: skill.directory.clone(),
readme_url: skill.readme_url.clone(),
installed: true,
repo_owner: skill.repo_owner.clone(),
repo_name: skill.repo_name.clone(),
repo_branch: skill.repo_branch.clone(),
});
}
}
skills.sort_by_key(|skill| skill.name.to_lowercase());
Ok(skills)
}
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
let (temp_guard, resolved_branch) =
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
let scan_dir = temp_guard.path();
let mut resolved_repo = repo.clone();
resolved_repo.branch = resolved_branch;
self.scan_dir_recursive(scan_dir, scan_dir, &resolved_repo, &mut skills)?;
Ok(skills)
}
/// 递归扫描目录查找 SKILL.md
fn scan_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &SkillRepo,
skills: &mut Vec<DiscoverableSkill>,
) -> Result<()> {
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
let directory = if current_dir == base_dir {
repo.name.clone()
} else {
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.replace('\\', "/")
};
let doc_path = skill_md
.strip_prefix(base_dir)
.unwrap_or(skill_md.as_path())
.to_string_lossy()
.replace('\\', "/");
if let Ok(skill) =
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
{
skills.push(skill);
}
return Ok(());
}
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
}
}
Ok(())
}
/// 从 SKILL.md 构建技能对象
fn build_skill_from_metadata(
&self,
skill_md: &Path,
directory: &str,
doc_path: &str,
repo: &SkillRepo,
) -> Result<DiscoverableSkill> {
let meta = self.parse_skill_metadata(skill_md)?;
Ok(DiscoverableSkill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Self::build_skill_doc_url(&repo.owner, &repo.name, &repo.branch, doc_path),
repo_owner: repo.owner.clone(),
repo_name: repo.name.clone(),
repo_branch: repo.branch.clone(),
})
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
Self::parse_skill_metadata_static(path)
}
/// 静态方法:解析技能元数据
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
let content = content.trim_start_matches('\u{feff}');
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok(SkillMetadata {
name: None,
description: None,
});
}
let front_matter = parts[1].trim();
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
name: None,
description: None,
});
Ok(meta)
}
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
if skill_md.exists() {
match Self::parse_skill_metadata_static(skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| fallback_name.to_string()),
meta.description,
),
Err(_) => (fallback_name.to_string(), None),
}
} else {
(fallback_name.to_string(), None)
}
}
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut normalized = PathBuf::new();
let mut has_component = false;
for component in Path::new(trimmed).components() {
match component {
Component::Normal(name) => {
let segment = name.to_string_lossy().trim().to_string();
if segment.is_empty() || segment == "." || segment == ".." {
return None;
}
normalized.push(segment);
has_component = true;
}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return None;
}
}
}
has_component.then_some(normalized)
}
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
fn sanitize_install_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
// 显式拒绝两种分隔符,不能依赖 components() 的平台语义:
// `\` 在 Linux/macOS 上不是分隔符,会被当成合法单段名放行,
// 但同一个值同步/还原到 Windows 上就变成了嵌套路径。
if trimmed.contains('/') || trimmed.contains('\\') {
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,
}
}
/// 校验来自 DB 行 / 备份 meta.json 等外部来源的 directory 字段。
///
/// 存储值按构造本应是单段安装名(见 sanitize_install_name),但有两个入口
/// 会绕过安装期校验:同步导入的远端快照直接灌库(raw SQL),以及手工放置 /
/// 不可信备份里的 meta.json。任何把它 join 进文件系统路径的使用点(尤其是
/// remove_dir_all 这类删除操作)必须先过这道校验,拒绝路径穿越。
///
/// 只校验、不归一化:`sanitize_install_name` 会 `trim()`,若拿它的返回值替换
/// 原值,磁盘上真实带空格的目录名就再也 join 不中。所以这里要求归一化结果与
/// 原值逐字相同,否则一律视为非法。
fn require_valid_directory(directory: &str) -> Result<String> {
match Self::sanitize_install_name(directory) {
Some(normalized) if normalized == directory => Ok(normalized),
_ => Err(anyhow!(
"Invalid skill directory (possible path traversal): {directory:?}"
)),
}
}
/// GitHub 账号名(user / org login)。
///
/// 只放行 ASCII 字母数字与 `-`。这比 GitHub 自身的规则更严,但该字段会被拼进
/// 下载 URL,任何 `/`、`.`、`%`、`\` 都可能改写请求落点(见 validate_repo_ref)。
fn is_valid_github_owner(owner: &str) -> bool {
!owner.is_empty()
&& owner.len() <= 39
&& owner.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
/// GitHub 仓库名。允许 `.` `-` `_`,但整体不能是 `.` 或 `..`。
fn is_valid_github_repo_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 100
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
/// git 分支名。
///
/// 分支名合法含 `/``feature/x`),所以不能整体禁掉分隔符——按段做白名单。
/// 逐段 `!starts_with('.')` 比整体 `contains("..")` 更稳:它同时挡掉 `a/./b`、
/// `a/.../b` 这类变形。除 `git check-ref-format` 的规则外还额外禁掉 `#` 与 `%`:
/// 前者会把 URL 后半截变成 fragment,后者可用百分号编码绕过字符检查。
fn is_valid_git_branch(branch: &str) -> bool {
// 空串和 "HEAD" 都是 `download_repo` 的哨兵,语义都是「用仓库默认分支」:
// 分支候选表对两者一视同仁地跳过,改试 main / master,所以它们**永远不会
// 被拼进 URL**,也就没有可校验的攻击面。空串必须放行——`skill_repos` 的
// 存量行可以是空 branch(建表默认值是 'main',但不禁止空串),前端两处
// `repo.branch || "main"` 就是照着这个前提写的。把它当非法会让那些仓库
// 在 download_repo 第一行就报 INVALID_REPO_REF,技能面板直接列不出来。
if branch.is_empty() || branch.eq_ignore_ascii_case("HEAD") {
return true;
}
if branch.len() > 255 {
return false;
}
if branch.starts_with('/') || branch.ends_with('/') || branch.contains("//") {
return false;
}
if branch.contains("@{") {
return false;
}
// `is_ascii_control()` 的范围是 U+0000..=U+001F **加上** U+007F DELETE
// 所以不需要另外再点名 DEL。
if branch
.chars()
.any(|c| c.is_ascii_control() || " ~^:?*[\\#%".contains(c))
{
return false;
}
branch.split('/').all(|segment| {
!segment.is_empty()
&& !segment.starts_with('.')
&& !segment.ends_with('.')
&& !segment.ends_with(".lock")
})
}
/// 校验一组仓库坐标,用于任何会被拼进 github.com URL 的地方。
///
/// 动机:`download_repo` 把 owner/name/branch 直接 format 进
/// `https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,而 URL
/// 解析会消解点段——branch 写成 `../../../releases/download/v1/evil` 时,落点变成
/// 该仓库的 **release asset**,即攻击者可上传的任意字节。归档内容一旦可控,
/// 解压路径校验就成了唯一防线,所以这一层必须堵死。
pub(crate) fn validate_repo_ref(owner: &str, name: &str, branch: &str) -> Result<()> {
if !Self::is_valid_github_owner(owner) || !Self::is_valid_github_repo_name(name) {
return Err(anyhow!(format_skill_error(
"INVALID_REPO_REF",
&[("owner", owner), ("name", name)],
Some("checkRepoUrl"),
)));
}
if !Self::is_valid_git_branch(branch) {
return Err(anyhow!(format_skill_error(
"INVALID_REPO_REF",
&[("owner", owner), ("name", name), ("branch", branch)],
Some("checkRepoUrl"),
)));
}
Ok(())
}
/// 出口断言:URL 拼好后再确认它确实指向预期的 github.com 路径。
///
/// 这是纵深防御——即便上面的字符集校验将来漏了某种变形(百分号编码、新的
/// 分隔符语义等),这里也能拦住落点被改写的请求。
fn assert_github_archive_url(url: &str, owner: &str, name: &str) -> Result<()> {
let parsed = url::Url::parse(url).map_err(|e| anyhow!("Invalid archive URL: {e}"))?;
let expected_prefix = format!("/{owner}/{name}/archive/refs/heads/");
if parsed.scheme() != "https"
|| parsed.host_str() != Some("github.com")
|| !parsed.path().starts_with(&expected_prefix)
{
return Err(anyhow!(format_skill_error(
"INVALID_REPO_REF",
&[("owner", owner), ("name", name)],
Some("checkRepoUrl"),
)));
}
Ok(())
}
/// 在目录树中查找名称匹配且包含 SKILL.md 的子目录
///
/// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"),
/// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。
fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option<PathBuf> {
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
if depth > 3 {
return None;
}
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() {
return Some(path);
}
if let Some(found) = walk(&path, target, depth + 1) {
return Some(found);
}
}
None
}
walk(root, target_name, 0)
}
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
///
/// 兼容三种情况:
/// 1. `skills/foo` 这类直接相对路径;
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let direct = root.join(&source_rel);
if direct.is_dir() {
return Some(direct);
}
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
target_name,
found.display()
);
return Some(found);
}
if root.is_dir() && root.join("SKILL.md").exists() {
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
target_name,
);
return Some(root.to_path_buf());
}
None
}
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 keyowner/repo:directory)作为唯一标识
// 这样不同仓库的同名 skill 会分开显示
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
e.insert(true);
true
} else {
false
}
});
}
/// 下载仓库
///
/// 这里是仓库坐标进入 URL 的**唯一收敛点**——`fetch_repo_skills`、`install`、
/// `check_updates`、`update_skill` 四条路径都经过它,而 `skill_repos` / `skills`
/// 两张表都会被同步导入的远端快照整表覆盖,入库校验管不住它们。所以主防线放这里。
async fn download_repo(&self, repo: &SkillRepo) -> Result<(tempfile::TempDir, String)> {
Self::validate_repo_ref(&repo.owner, &repo.name, &repo.branch)?;
// 守卫全程持有,成功后连同目录一起交给调用方(见 `extract_local_zip` 的说明)。
// 原来这里立刻 keep(),任何一步失败——下载超时、ARCHIVE_TOO_LARGE、解压出错
// ——都会把半个解压目录永久留在磁盘上,反复触发即可持续填盘。
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let mut branches = Vec::new();
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
branches.push("main");
}
if !branches.contains(&"master") {
branches.push("master");
}
let mut last_error = None;
for branch in branches {
let url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo.owner, repo.name, branch
);
Self::assert_github_archive_url(&url, &repo.owner, &repo.name)?;
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => return Ok((temp_dir, branch.to_string())),
Err(e) => {
// 每个分支各自重算预算,所以失败后必须把上一轮的残留清掉——
// 否则 N 个候选分支等于 N 倍的落盘量堆在同一个目录里。
let _ = fs::remove_dir_all(&temp_path);
let _ = fs::create_dir_all(&temp_path);
last_error = Some(e);
continue;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
}
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let client = crate::proxy::http_client::get();
let response = client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
"DOWNLOAD_FAILED",
&[("status", &status)],
match status.as_str() {
"403" => Some("http403"),
"404" => Some("http404"),
"429" => Some("http429"),
_ => Some("checkNetwork"),
},
)));
}
// 逐块读并卡住压缩体大小:`response.bytes()` 会先把攻击者控制的整个归档
// 收进内存,之后才轮到 ZipArchive 和解压预算——那时候堆已经被吃光了。
// 不能只信 Content-Length(可以撒谎或缺失),必须按实际收到的字节数算。
let mut response = response;
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await? {
if body.len().saturating_add(chunk.len()) as u64 > MAX_ARCHIVE_DOWNLOAD_BYTES {
let limit_mb = (MAX_ARCHIVE_DOWNLOAD_BYTES / 1024 / 1024).to_string();
return Err(anyhow::anyhow!(format_skill_error(
"ARCHIVE_TOO_LARGE",
&[("limit_mb", &limit_mb)],
Some("checkZipContent"),
)));
}
body.extend_from_slice(&chunk);
}
let cursor = std::io::Cursor::new(body);
let archive = zip::ZipArchive::new(cursor)?;
Self::extract_repo_archive(archive, dest)
}
/// 按预算把单个归档条目写出,累计超限即中止。
///
/// 逐块累加而非读取归档头里声明的 size:那个值由归档作者填写,压缩炸弹会撒谎。
fn copy_entry_within_budget<R: std::io::Read, W: std::io::Write>(
reader: &mut R,
writer: &mut W,
total_bytes: &mut u64,
) -> Result<()> {
let mut buffer = [0u8; 16 * 1024];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
return Ok(());
}
Self::charge_archive_budget(total_bytes, read as u64)?;
writer.write_all(&buffer[..read])?;
}
}
/// 读取 symlink 条目声明的目标路径。
///
/// 这条分支曾是唯一一处不经预算的解压:`read_to_string` 直接把整条解压流吞进
/// 内存,而 zip 2.4.2 的 `make_reader`read.rs:437-449)只叠了 CRC 校验,
/// **没有**按声明的 uncompressed_size 截断。于是一个打着 symlink 标志、
/// deflate 后能膨胀到数 GB 的条目就是一颗内存炸弹,且预算读数全程为 0。
///
/// 超长或非 UTF-8 一律返回 `None` 让调用方跳过:合法的 symlink 目标是一条
/// 路径,这两种形状都不可能是真实数据。
fn read_symlink_target<R: std::io::Read>(
reader: &mut R,
total_bytes: &mut u64,
) -> Result<Option<String>> {
let mut raw = Vec::new();
// 多读一个字节,用来区分"正好到上限"和"被截断"
let mut limited = std::io::Read::take(reader, MAX_SYMLINK_TARGET_BYTES + 1);
std::io::Read::read_to_end(&mut limited, &mut raw)?;
if raw.len() as u64 > MAX_SYMLINK_TARGET_BYTES {
return Ok(None);
}
Self::charge_archive_budget(total_bytes, raw.len() as u64)?;
Ok(String::from_utf8(raw)
.ok()
.map(|target| target.trim().to_string()))
}
/// 建目录并按**实际新建的层数**计费。
///
/// `create_dir_all` 会一次性把缺失的父目录全建出来,所以一个条目名
/// `a/a/…/a/f.txt` 可以隐式造出几百层目录。只在 symlink 物化那条路径上给目录
/// 计费是不够的:常规解压这条路上,不到 10_000 个条目照样能造出数百万目录,
/// 而内容字节几乎为零。
fn create_dir_all_within_budget(path: &Path, total_bytes: &mut u64) -> Result<()> {
let missing = path.ancestors().take_while(|p| !p.exists()).count() as u64;
if missing > 0 {
Self::charge_archive_budget(total_bytes, missing * DIRECTORY_BUDGET_COST)?;
}
fs::create_dir_all(path)?;
Ok(())
}
/// 归档预算的唯一扣费点。
///
/// 抽出来是因为「写文件内容」不是归档能消耗的唯一资源:symlink 物化出来的
/// 目录一个字节都不写,但每一个都要占 inode 与一个目录块,而第二遍的
/// symlink 解析可以让目录数量按层数指数增长。只按内容字节计费时,一个全是
/// 空目录的归档能把预算读数一直停在 0。
fn charge_archive_budget(total_bytes: &mut u64, amount: u64) -> Result<()> {
if total_bytes.saturating_add(amount) > MAX_ARCHIVE_TOTAL_BYTES {
let limit_mb = (MAX_ARCHIVE_TOTAL_BYTES / 1024 / 1024).to_string();
return Err(anyhow::anyhow!(format_skill_error(
"ARCHIVE_TOO_LARGE",
&[("limit_mb", &limit_mb)],
Some("checkZipContent"),
)));
}
*total_bytes += amount;
Ok(())
}
/// 把 GitHub 仓库归档解压到 `dest`(剥掉归档自带的一层根目录)。
///
/// 与 `download_and_extract` 分离,使 zip-slip 防护可在不联网的情况下被单测覆盖。
fn extract_repo_archive<R: std::io::Read + std::io::Seek>(
mut archive: zip::ZipArchive<R>,
dest: &Path,
) -> Result<()> {
let root_name = if !archive.is_empty() {
let first_file = archive.by_index(0)?;
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
};
// 归档字节完全由第三方控制(仓库可经 deeplink 添加),所以解压必须限量,
// 否则一个几 MB 的压缩炸弹就能塞满磁盘。webdav_sync/archive.rs 早有同款
// 双重上限,这条下载路径一直没有。
if archive.len() > MAX_ARCHIVE_ENTRIES {
let count = archive.len().to_string();
let limit = MAX_ARCHIVE_ENTRIES.to_string();
return Err(anyhow::anyhow!(format_skill_error(
"ARCHIVE_TOO_MANY_ENTRIES",
&[("count", &count), ("limit", &limit)],
Some("checkZipContent"),
)));
}
let mut total_bytes: u64 = 0;
// 第一遍:解压普通文件和目录,收集 symlink 条目
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
// 第一道:enclosed_name() 拒绝绝对路径、盘符前缀,以及净深度为负
// (即逃出归档自身根目录)的条目。skill 仓库可由 deeplink 添加,
// 压缩包内容属第三方可控输入。
let Some(safe_path) = file.enclosed_name() else {
log::warn!("跳过不安全的压缩包条目: {}", file.name());
continue;
};
// GitHub 归档统一带一层 `<repo>-<branch>/` 根目录,需剥掉后再落盘。
let Ok(relative_path) = safe_path.strip_prefix(&root_name) else {
continue;
};
// 第二道:enclosed_name() 的保证是相对**归档根**的,且它不规范化路径
// ——`..` 会原样留在返回值里。上面剥掉 root_name 等于花掉一级深度预算,
// 于是 `repo-main/../evil` 这类条目仍能落到 dest 之外(Unix 逃一层;
// Windows 上 root_name 可含反斜杠而被当作多段,逃逸深度随之放大)。
// 因此 join 之前必须对**实际使用的相对路径**再验一次。
if relative_path
.components()
.any(|c| matches!(c, Component::ParentDir))
{
log::warn!("跳过越界的压缩包条目: {}", file.name());
continue;
}
if relative_path.as_os_str().is_empty() {
continue;
}
let outpath = dest.join(relative_path);
if file.is_symlink() {
let Some(target) = Self::read_symlink_target(&mut file, &mut total_bytes)? else {
log::warn!("跳过目标不合法的 symlink 条目: {}", file.name());
continue;
};
symlinks.push((outpath, target));
} else if file.is_dir() {
Self::create_dir_all_within_budget(&outpath, &mut total_bytes)?;
} else {
if let Some(parent) = outpath.parent() {
Self::create_dir_all_within_budget(parent, &mut total_bytes)?;
}
let mut outfile = fs::File::create(&outpath)?;
// 按实际写入的字节累计,而不是信任归档头里声明的 size——
// 压缩炸弹的声明值可以是假的。
Self::copy_entry_within_budget(&mut file, &mut outfile, &mut total_bytes)?;
}
}
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
Self::resolve_symlinks_in_dir(dest, &symlinks, &mut total_bytes)?;
Ok(())
}
/// 与 `copy_dir_recursive` 同语义,但把写出的字节计入归档总预算。
/// 仅用于解压期间物化 symlink——常规的目录复制(安装、备份、迁移)不该受
/// 归档预算约束,所以两个函数刻意不合并。
fn copy_dir_within_budget(src: &Path, dest: &Path, total_bytes: &mut u64) -> Result<()> {
Self::create_dir_all_within_budget(dest, total_bytes)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest_path = dest.join(entry.file_name());
if path.is_dir() {
Self::copy_dir_within_budget(&path, &dest_path, total_bytes)?;
} else {
Self::copy_file_within_budget(&path, &dest_path, total_bytes)?;
}
}
Ok(())
}
/// 复制单个文件并计入归档总预算,复用 `copy_entry_within_budget` 以保证
/// 上限与报错文案只有一处定义。
fn copy_file_within_budget(src: &Path, dest: &Path, total_bytes: &mut u64) -> Result<()> {
let mut reader = fs::File::open(src)?;
let mut writer = fs::File::create(dest)?;
Self::copy_entry_within_budget(&mut reader, &mut writer, total_bytes)
}
/// 递归复制目录
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest_path = dest.join(entry.file_name());
if path.is_dir() {
Self::copy_dir_recursive(&path, &dest_path)?;
} else {
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
// 返回值会被整目录复制进 ~/.cc-switch/skill-backups/ 并由 get_skill_backups
// 在界面上列出——脏 directory 在这里等于任意文件读取 + 外泄通道。
let directory = Self::require_valid_directory(&skill.directory)?;
let ssot_path = Self::get_ssot_dir()?.join(&directory);
if ssot_path.is_dir() {
return Ok(Some(ssot_path));
}
for app in AppType::all() {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(dir) => dir,
Err(_) => continue,
};
let candidate = app_dir.join(&directory);
if candidate.is_dir() {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn sanitize_backup_segment(segment: &str) -> String {
let sanitized = segment
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
"skill".to_string()
} else {
sanitized
}
}
fn cleanup_old_skill_backups(dir: &Path) -> Result<()> {
let mut entries = fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
if !metadata.is_dir() {
return None;
}
Some((entry.path(), metadata.modified().ok()))
})
.collect::<Vec<_>>();
if entries.len() <= SKILL_BACKUP_RETAIN_COUNT {
return Ok(());
}
entries.sort_by_key(|(_, modified)| *modified);
let remove_count = entries.len().saturating_sub(SKILL_BACKUP_RETAIN_COUNT);
for (path, _) in entries.into_iter().take(remove_count) {
fs::remove_dir_all(&path)?;
}
Ok(())
}
fn backup_path_for_id(backup_id: &str) -> Result<PathBuf> {
if backup_id.contains("..")
|| backup_id.contains('/')
|| backup_id.contains('\\')
|| backup_id.trim().is_empty()
{
return Err(anyhow!("Invalid backup id: {backup_id}"));
}
Ok(Self::get_backup_dir()?.join(backup_id))
}
fn read_backup_metadata(backup_path: &Path) -> Result<SkillBackupMetadata> {
let metadata_path = backup_path.join("meta.json");
let content = fs::read_to_string(&metadata_path)
.with_context(|| format!("failed to read {}", metadata_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", metadata_path.display()))
}
fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {
log::warn!(
"Skill {} 卸载前未找到可备份的目录,将跳过备份",
skill.directory
);
return Ok(None);
};
let backup_root = Self::get_backup_dir()?;
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let slug = Self::sanitize_backup_segment(&skill.directory);
let mut backup_path = backup_root.join(format!("{timestamp}_{slug}"));
let mut counter = 1;
while backup_path.exists() {
backup_path = backup_root.join(format!("{timestamp}_{slug}_{counter}"));
counter += 1;
}
let write_backup = || -> Result<()> {
let skill_backup_dir = backup_path.join("skill");
Self::copy_dir_recursive(&source_path, &skill_backup_dir)?;
let metadata = SkillBackupMetadata {
skill: skill.clone(),
backup_created_at: Utc::now().timestamp(),
source_path: source_path.to_string_lossy().to_string(),
};
let metadata_path = backup_path.join("meta.json");
let metadata_json = serde_json::to_string_pretty(&metadata)
.context("failed to serialize skill backup metadata")?;
fs::write(&metadata_path, metadata_json)
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
Ok(())
};
if let Err(err) = write_backup() {
let _ = fs::remove_dir_all(&backup_path);
return Err(err);
}
if let Err(err) = Self::cleanup_old_skill_backups(&backup_root) {
log::warn!("清理旧 Skill 备份失败: {err:#}");
}
log::info!(
"Skill {} 已在卸载前备份到 {}",
skill.name,
backup_path.display()
);
Ok(Some(backup_path))
}
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
///
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
/// 以确保跨平台兼容且 skill 内容自包含。
fn resolve_symlinks_in_dir(
base_dir: &Path,
symlinks: &[(PathBuf, String)],
total_bytes: &mut u64,
) -> Result<()> {
// 规范化 base_dirmacOS 上 /tmp → /private/tmp,需保持一致)
let canonical_base = base_dir
.canonicalize()
.unwrap_or_else(|_| base_dir.to_path_buf());
for (link_path, target) in symlinks {
// 计算 symlink 的父目录,然后拼接目标的相对路径
let parent = link_path.parent().unwrap_or(base_dir);
let resolved = parent.join(target);
// 规范化路径(解析 .. 等)
let resolved = match resolved.canonicalize() {
Ok(p) => p,
Err(_) => {
log::warn!(
"Symlink 目标不存在,跳过: {} -> {}",
link_path.display(),
target
);
continue;
}
};
// 安全检查一:确保目标在 base_dir 内(防止路径穿越)
if !resolved.starts_with(&canonical_base) {
log::warn!(
"Symlink 目标超出仓库范围,跳过: {} -> {}",
link_path.display(),
resolved.display()
);
continue;
}
// 安全检查二:目标不能包含 link 自身。上面那条防的是「跑出 base」,
// 防不住「套进自己」——`dir/link -> ..` 解析后正是 base 本身,完全
// 合规,随后递归复制会把归档根复制进自己的子目录;每递归一层都重新
// 看到刚落盘的副本,目录树逐层膨胀直到 PATH_MAX 才失败。
//
// 比较必须在**规范形式**上做:`enclosed_name()` 不规范化路径,只保证
// 净深度非负,所以 link_path 里可能带着未消解的 `..``e/../d/self`)。
// 拿它按字面跟 canonicalize 过的 resolved 比组件,第一段就会错开
// (`e` vs `d`),检查形同虚设。link_path 自身此刻尚未落盘,但它的父
// 目录一定存在——`resolved` 能 canonicalize 成功就蕴含了这一点。
let canonical_link = match parent.canonicalize() {
Ok(canonical_parent) => match link_path.file_name() {
Some(name) => canonical_parent.join(name),
None => canonical_parent,
},
// 父目录都不存在时退回字面形式:此时 resolved 多半也解析不出来,
// 上面就已经 continue 了;留着只是不让守卫在意外形状上 panic。
Err(_) => match link_path.strip_prefix(base_dir) {
Ok(relative) => canonical_base.join(relative),
Err(_) => link_path.clone(),
},
};
if canonical_link.starts_with(&resolved) {
log::warn!(
"Symlink 目标包含链接自身,跳过(会导致递归自复制): {} -> {}",
link_path.display(),
resolved.display()
);
continue;
}
// 复制目标内容到 symlink 位置。必须与解压循环共用同一个字节预算:
// 物化走的是这条独立路径,不计费的话「一个大文件 + N 个指向它的
// symlink」能写下 N 倍字节,而 MAX_ARCHIVE_TOTAL_BYTES 全程显示合规。
if resolved.is_dir() {
Self::copy_dir_within_budget(&resolved, link_path, total_bytes)?;
} else if resolved.is_file() {
if let Some(parent) = link_path.parent() {
Self::create_dir_all_within_budget(parent, total_bytes)?;
}
Self::copy_file_within_budget(&resolved, link_path, total_bytes)?;
}
}
Ok(())
}
// ========== 从 ZIP 文件安装 ==========
/// 从本地 ZIP 文件安装 Skills
///
/// 流程:
/// 1. 解压 ZIP 到临时目录
/// 2. 扫描目录查找包含 SKILL.md 的技能
/// 3. 复制到 SSOT 并保存到数据库
/// 4. 同步到当前应用目录
pub fn install_from_zip(
db: &Arc<Database>,
zip_path: &Path,
current_app: &AppType,
) -> Result<Vec<InstalledSkill>> {
// 解压到临时目录
let temp_guard = Self::extract_local_zip(zip_path)?;
let temp_dir = temp_guard.path();
// 扫描所有包含 SKILL.md 的目录
let skill_dirs = Self::scan_skills_in_dir(temp_dir)?;
if skill_dirs.is_empty() {
return Err(anyhow!(format_skill_error(
"NO_SKILLS_IN_ZIP",
&[],
Some("checkZipContent"),
)));
}
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 {
// 解析元数据(提前解析,用于确定安装名)
let skill_md = skill_dir.join("SKILL.md");
let meta = if skill_md.exists() {
Self::parse_skill_metadata_static(&skill_md).ok()
} else {
None
};
// 获取目录名称作为安装名
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
let install_name = {
let dir_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if skill_dir.as_path() == temp_dir
|| dir_name.is_empty()
|| dir_name.starts_with('.')
{
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
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))
} else {
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 => {
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("zip", &zip_path.display().to_string())],
Some("checkZipContent"),
)));
}
};
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
.values()
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
if let Some(existing) = conflict {
log::warn!(
"Skill directory '{}' already exists (from {}), skipping",
install_name,
existing.id
);
continue;
}
let (name, description) = match meta {
Some(m) => (
m.name.unwrap_or_else(|| install_name.clone()),
m.description,
),
None => (install_name.clone(), None),
};
// 复制到 SSOT
let dest = ssot_dir.join(&install_name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).ok();
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
id: format!("local:{install_name}"),
name,
description,
directory: install_name.clone(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
skill.name,
current_app
);
installed.push(skill);
}
Ok(installed)
}
/// 解压本地 ZIP 文件到临时目录
///
/// 返回 `TempDir` 而不是 `PathBuf`:调用方在解压之后还有扫描、复制、写库、
/// 同步等一长串 `?`,任何一处提前返回都会把最多 512 MiB 的临时内容永久留在
/// 磁盘上。守卫交给调用方持有,清理就变成作用域结束时自动发生,不再依赖每条
/// 出口都记得手写 `remove_dir_all`(实测漏了不止一条)。
fn extract_local_zip(zip_path: &Path) -> Result<tempfile::TempDir> {
Self::extract_local_zip_in(zip_path, &std::env::temp_dir())
}
/// 与 [`Self::extract_local_zip`] 相同,但临时目录的落点由调用方指定。
/// 测试用它把解压根钉在私有目录里,而不是劫持进程级 `TMPDIR`——后者会把
/// 并发测试的临时目录一起吸进被观测目录,"目录必须为空"的断言就会随机失败。
fn extract_local_zip_in(zip_path: &Path, base_dir: &Path) -> Result<tempfile::TempDir> {
let file = fs::File::open(zip_path)
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
if archive.is_empty() {
return Err(anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkZipContent"),
)));
}
// 与远端归档同一套上限。本地 ZIP 是用户自选文件(信任度更高),但"用户
// 被诱导打开一个压缩炸弹"仍是常见路径,且两条解压路径共用同一个物化器。
if archive.len() > MAX_ARCHIVE_ENTRIES {
let count = archive.len().to_string();
let limit = MAX_ARCHIVE_ENTRIES.to_string();
return Err(anyhow!(format_skill_error(
"ARCHIVE_TOO_MANY_ENTRIES",
&[("count", &count), ("limit", &limit)],
Some("checkZipContent"),
)));
}
// 守卫持有到解压全部成功为止:中途任何 `?` 都会让它清掉半成品目录。
// 原来在这里就 keep(),超限或解压出错都会留下永久残留。
let temp_dir = tempfile::tempdir_in(base_dir)?;
let temp_path = temp_dir.path().to_path_buf();
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
let mut total_bytes: u64 = 0;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
// `enclosed_name()` 只保证净深度非负,**不消解** `..`。这里没有
// `strip_prefix` 吃掉深度预算,所以逃不出 temp_path;但留着未消解的
// 路径会让后面的 symlink 自包含检查失去可比性(`e/../d/self` 与 `d`
// 逐组件比在第一段就错开)。在入口就把它们挡掉,保证落进 symlinks
// 表里的路径都是规范形状。
if file_path
.components()
.any(|c| matches!(c, Component::ParentDir))
{
log::warn!("跳过越界的压缩包条目: {}", file.name());
continue;
}
let outpath = temp_path.join(&file_path);
if file.is_symlink() {
let Some(target) = Self::read_symlink_target(&mut file, &mut total_bytes)? else {
log::warn!("跳过目标不合法的 symlink 条目: {}", file.name());
continue;
};
symlinks.push((outpath, target));
} else if file.is_dir() {
Self::create_dir_all_within_budget(&outpath, &mut total_bytes)?;
} else {
if let Some(parent) = outpath.parent() {
Self::create_dir_all_within_budget(parent, &mut total_bytes)?;
}
let mut outfile = fs::File::create(&outpath)?;
Self::copy_entry_within_budget(&mut file, &mut outfile, &mut total_bytes)?;
}
}
// 解析 symlink
Self::resolve_symlinks_in_dir(&temp_path, &symlinks, &mut total_bytes)?;
Ok(temp_dir)
}
/// 递归扫描目录查找包含 SKILL.md 的技能目录
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
let mut skill_dirs = Vec::new();
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
Ok(skill_dirs)
}
/// 递归扫描辅助函数
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current.join("SKILL.md");
if skill_md.exists() {
results.push(current.to_path_buf());
// 找到后不再递归子目录(一个 skill 目录)
return Ok(());
}
// 递归子目录
if let Ok(entries) = fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// 跳过隐藏目录
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
Self::scan_skills_recursive(&path, results)?;
}
}
}
Ok(())
}
// ========== 仓库管理(保留原有逻辑)==========
/// 列出仓库
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
store.repos.clone()
}
/// 添加仓库
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
if let Some(pos) = store
.repos
.iter()
.position(|r| r.owner == repo.owner && r.name == repo.name)
{
store.repos[pos] = repo;
} else {
store.repos.push(repo);
}
Ok(())
}
/// 删除仓库
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
store
.repos
.retain(|r| !(r.owner == owner && r.name == name));
Ok(())
}
// ========== skills.sh 搜索 ==========
/// 搜索 skills.sh 公共目录
pub async fn search_skills_sh(
query: &str,
limit: usize,
offset: usize,
) -> Result<SkillsShSearchResult> {
let client = crate::proxy::http_client::get();
let url = url::Url::parse_with_params(
"https://skills.sh/api/search",
&[
("q", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
],
)?;
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await?
.error_for_status()?
.json::<SkillsShApiResponse>()
.await?;
let skills = resp
.skills
.into_iter()
.filter_map(|s| {
let parts: Vec<&str> = s.source.splitn(2, '/').collect();
if parts.len() != 2 {
return None;
}
let (owner, repo) = (parts[0].to_string(), parts[1].to_string());
// 用与 download_repo 同一套坐标校验,而不是就地写启发式:下面这个
// readme_url 最终交给 openExternal 打开,是和 build_skill_doc_url
// 同一个 sink。原来的 `contains('.')` 既漏(`splitn(2, '/')` 允许
// repo 里带 `/``owner/a/b` 能拼出三段路径),又误伤(GitHub 仓库
// 名合法含点)。校验 owner 同时也保留了"过滤非 GitHub 来源"的效果
// ——`skills.volces.com` 这类带点的 owner 本来就不是合法用户名。
if Self::validate_repo_ref(&owner, &repo, "main").is_err() {
return None;
}
Some(SkillsShDiscoverableSkill {
key: s.id,
name: s.name,
directory: s.skill_id.clone(),
repo_owner: owner.clone(),
repo_name: repo.clone(),
repo_branch: "main".to_string(),
installs: s.installs,
readme_url: Some(format!("https://github.com/{}/{}", owner, repo)),
})
})
.collect();
Ok(SkillsShSearchResult {
skills,
total_count: resp.count,
query: resp.query,
})
}
}
// ========== 迁移支持 ==========
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
///
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
fn build_repo_info_from_lock(
lock: &HashMap<String, LockRepoInfo>,
dir_name: &str,
) -> (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
match lock.get(dir_name) {
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 = info.skill_path.as_deref().unwrap_or(&fallback);
let url =
SkillService::build_skill_doc_url(&info.owner, &info.repo, &url_branch, doc_path);
(
format!("{}/{}:{dir_name}", info.owner, info.repo),
Some(info.owner.clone()),
Some(info.repo.clone()),
branch,
url,
)
}
None => (format!("local:{dir_name}"), None, None, None, None),
}
}
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
fn save_repos_from_lock(
db: &Arc<Database>,
lock: &HashMap<String, LockRepoInfo>,
directories: impl Iterator<Item = impl AsRef<str>>,
) {
let existing_repos: HashSet<(String, String)> = db
.get_skill_repos()
.unwrap_or_default()
.into_iter()
.map(|r| (r.owner, r.name))
.collect();
let mut added = HashSet::new();
for dir_name in directories {
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: info.owner.clone(),
name: info.repo.clone(),
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
enabled: true,
};
// lock 文件由外部 agents CLI 写入,owner/repo/branch 均未经校验,
// 且 branch 是从 `/tree/`、fragment、`?ref=` 里抠出来的裸串。
if SkillService::validate_repo_ref(
&skill_repo.owner,
&skill_repo.name,
&skill_repo.branch,
)
.is_err()
{
log::warn!(
"跳过 agents lock 中坐标非法的仓库: {}/{}@{}",
skill_repo.owner,
skill_repo.name,
skill_repo.branch
);
continue;
}
if let Err(e) = db.save_skill_repo(&skill_repo) {
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
} else {
log::info!(
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
info.owner,
info.repo,
skill_repo.branch
);
}
}
}
}
}
/// 首次启动迁移:扫描应用目录,重建数据库
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let ssot_dir = SkillService::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let snapshot: Vec<LegacySkillMigrationRow> =
match db.get_setting("skills_ssot_migration_snapshot")? {
Some(value) if !value.trim().is_empty() => match serde_json::from_str(&value) {
Ok(rows) => rows,
Err(err) => {
log::warn!("解析 skills 迁移快照失败,将回退到文件系统扫描: {err}");
Vec::new()
}
},
_ => Vec::new(),
};
let has_snapshot = !snapshot.is_empty();
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
if has_snapshot {
for row in &snapshot {
// snapshot 存在 settings 表里,而 settings 在同步范围内、可被远端快照
// 覆盖。下面 discovered 的每个 key 都会被 join 成路径并写回 skills 表,
// 所以脏值必须在进入 discovered 之前就滤掉。
if SkillService::require_valid_directory(&row.directory).is_err() {
log::warn!("跳过 SSOT 迁移快照中非法的 directory: {:?}", row.directory);
continue;
}
if let Ok(app) = row.app_type.parse::<AppType>() {
discovered
.entry(row.directory.clone())
.or_default()
.set_enabled_for(&app, true);
}
}
}
// 扫描各应用目录
for app in AppType::all() {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
let entries = match fs::read_dir(&app_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
if !path.join("SKILL.md").exists() {
continue;
}
if has_snapshot && !discovered.contains_key(&dir_name) {
continue;
}
// 复制到 SSOT(如果不存在)
let ssot_path = ssot_dir.join(&dir_name);
if !ssot_path.exists() {
SkillService::copy_dir_recursive(&path, &ssot_path)?;
}
if !has_snapshot {
discovered
.entry(dir_name)
.or_default()
.set_enabled_for(&app, true);
}
}
}
// 重建数据库
db.clear_skills()?;
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, discovered.keys());
let mut count = 0;
for (directory, apps) in discovered {
let ssot_path = ssot_dir.join(&directory);
let skill_md = ssot_path.join("SKILL.md");
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &directory);
let content_hash = SkillService::compute_dir_hash(&ssot_path).ok();
let skill = InstalledSkill {
id,
name,
description,
directory,
repo_owner,
repo_name,
repo_branch,
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
db.save_skill(&skill)?;
count += 1;
}
let _ = db.set_setting("skills_ssot_migration_snapshot", "");
log::info!("Skills 迁移完成,共 {count} 个");
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
/// 构造一个模拟 GitHub 归档的 ZIP:带一层 `repo-main/` 根目录,
/// 其中掺入用 `../` 逃逸的恶意条目。
///
/// 两个恶意条目走的是**不同**的拦截层,缺一不可:
/// - 两级 `../../`:净深度为负,`enclosed_name()` 自己就会拒绝;
/// - 一级 `../`:净深度非负,`enclosed_name()` **放行**且原样保留 `..`
/// 只有剥掉 root_name 之后的组件校验才能拦住。
fn build_zip_with_traversal_entry() -> Vec<u8> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
// 合法条目:会被正常解压
zip.start_file("repo-main/SKILL.md", opts).unwrap();
zip.write_all(b"---\nname: ok\n---\n").unwrap();
// 恶意条目 A:被 enclosed_name() 拒绝
zip.start_file("repo-main/../../escaped.txt", opts).unwrap();
zip.write_all(b"pwned").unwrap();
// 恶意条目 B:能通过 enclosed_name(),靠组件校验拦截
zip.start_file("repo-main/../escaped-one-level.txt", opts)
.unwrap();
zip.write_all(b"pwned").unwrap();
zip.finish().unwrap();
}
buf
}
#[test]
fn validate_repo_ref_accepts_real_world_coordinates() {
// 合法分支名允许 `/`,不能因为防穿越就把它们一起禁掉
for branch in [
"main",
"master",
"HEAD",
"feature/new-thing",
"release/v1.2.3",
"fix-123",
"user.name/topic",
] {
assert!(
SkillService::validate_repo_ref("farion1231", "cc-switch", branch).is_ok(),
"must accept branch: {branch:?}"
);
}
assert!(SkillService::validate_repo_ref("a", "b.c_d-e", "main").is_ok());
}
#[test]
fn validate_repo_ref_accepts_the_empty_branch_sentinel() {
// 空 branch 与 "HEAD" 在 download_repo 里是同一个哨兵:分支候选表跳过
// 两者,改试 main / master,所以它们从不进 URL。校验若把空串当非法,
// 存量 skill_repos 行(建表默认 'main',但空串没被禁)会在 download_repo
// 第一行就 INVALID_REPO_REF,整个技能面板列不出东西——前端两处
// `repo.branch || "main"` 正是照着"空串可用"写的。
assert!(
SkillService::validate_repo_ref("farion1231", "cc-switch", "").is_ok(),
"the empty-branch sentinel must stay usable"
);
}
#[test]
fn validate_repo_ref_rejects_url_hijacking_branches() {
// 这是核心用例:branch 被拼进 archive URLURL 解析会消解点段,
// 落点会从 /archive/refs/heads/ 改写成攻击者可上传的 release asset。
for branch in [
"../../../releases/download/v1/evil",
"..",
"../x",
"a/../../b",
"a/./b",
"..\\..\\releases\\download\\v1\\evil",
"/leading",
"trailing/",
"double//slash",
"with space",
"frag#ment",
"pct%2e%2e",
"ref@{0}",
"seg.lock",
".hidden/x",
] {
assert!(
SkillService::validate_repo_ref("owner", "repo", branch).is_err(),
"must reject branch: {branch:?}"
);
}
for (owner, name) in [
("..", "repo"),
("own/er", "repo"),
("owner", ".."),
("owner", "re/po"),
("owner", "re po"),
("", "repo"),
("owner", ""),
] {
assert!(
SkillService::validate_repo_ref(owner, name, "main").is_err(),
"must reject coordinates: {owner:?}/{name:?}"
);
}
}
#[test]
fn assert_github_archive_url_pins_host_and_path() {
let ok = "https://github.com/owner/repo/archive/refs/heads/main.zip";
assert!(SkillService::assert_github_archive_url(ok, "owner", "repo").is_ok());
// 出口断言必须挡住落点被改写到 release asset 的情况
for bad in [
"https://github.com/owner/repo/releases/download/v1/evil.zip",
"https://evil.example/owner/repo/archive/refs/heads/main.zip",
"http://github.com/owner/repo/archive/refs/heads/main.zip",
"https://github.com/other/repo/archive/refs/heads/main.zip",
] {
assert!(
SkillService::assert_github_archive_url(bad, "owner", "repo").is_err(),
"must reject url: {bad}"
);
}
}
#[test]
fn build_skill_doc_url_drops_illegal_coordinates() {
assert_eq!(
SkillService::build_skill_doc_url("owner", "repo", "main", "a/SKILL.md").as_deref(),
Some("https://github.com/owner/repo/blob/main/a/SKILL.md")
);
// readme_url 会被前端 openExternal 直接打开,非法坐标不得产出链接
assert!(
SkillService::build_skill_doc_url("owner", "repo", "../../../issues", "x").is_none()
);
}
#[test]
fn copy_entry_within_budget_stops_before_exceeding_the_limit() {
// 预算逐块累加,超限时中止且不再继续写——压缩炸弹声明的 size 不可信,
// 所以判断只能基于实际读到的字节。
let mut total = MAX_ARCHIVE_TOTAL_BYTES - 8;
let mut reader = std::io::Cursor::new(vec![7u8; 64]);
let mut writer: Vec<u8> = Vec::new();
let err = SkillService::copy_entry_within_budget(&mut reader, &mut writer, &mut total)
.expect_err("must reject once the budget is exhausted");
assert!(
err.to_string().contains("ARCHIVE_TOO_LARGE"),
"unexpected error: {err}"
);
assert!(
writer.is_empty(),
"nothing may be written once the chunk would exceed the budget"
);
// 预算充足时照常写完
let mut total = 0u64;
let mut reader = std::io::Cursor::new(vec![7u8; 64]);
let mut writer: Vec<u8> = Vec::new();
SkillService::copy_entry_within_budget(&mut reader, &mut writer, &mut total)
.expect("within budget");
assert_eq!(writer.len(), 64);
assert_eq!(total, 64);
}
#[test]
fn extract_repo_archive_rejects_too_many_entries() {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
for i in 0..(MAX_ARCHIVE_ENTRIES + 1) {
zip.start_file(format!("repo-main/f{i}"), opts).unwrap();
zip.write_all(b"x").unwrap();
}
zip.finish().unwrap();
}
let temp = tempdir().expect("tempdir");
let archive = zip::ZipArchive::new(std::io::Cursor::new(buf)).expect("archive parses");
let err = SkillService::extract_repo_archive(archive, temp.path())
.expect_err("entry count over the limit must be rejected");
assert!(
err.to_string().contains("ARCHIVE_TOO_MANY_ENTRIES"),
"unexpected error: {err}"
);
}
#[test]
fn extract_repo_archive_rejects_path_traversal_entries() {
let temp = tempdir().expect("tempdir");
// dest 放在深一层,这样逃逸一层/两层都落在 temp 内、可被检出
let dest = temp.path().join("nested").join("dest");
fs::create_dir_all(&dest).expect("create dest");
let bytes = build_zip_with_traversal_entry();
let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("archive parses");
SkillService::extract_repo_archive(archive, &dest).expect("extract must not fail");
// 合法条目正常落盘
assert!(
dest.join("SKILL.md").is_file(),
"legitimate entry should be extracted"
);
// 两级逃逸:不得写到 dest 之外
assert!(
!temp.path().join("escaped.txt").exists(),
"zip-slip entry must not escape dest (temp root)"
);
assert!(
!temp.path().join("nested").join("escaped.txt").exists(),
"zip-slip entry must not escape dest (parent dir)"
);
// 一级逃逸:enclosed_name() 放行的那一类,必须被组件校验拦住
assert!(
!temp
.path()
.join("nested")
.join("escaped-one-level.txt")
.exists(),
"single-`..` entry must not escape dest (enclosed_name allows it)"
);
}
#[test]
fn extract_repo_archive_skips_a_symlink_that_contains_itself() {
// `dir/link -> ..` 解析后正是归档根:它**通过**「目标必须在 base 内」的
// 检查,因为目标就是 base 本身。没有第二道自包含检查时,
// copy_dir_recursive(base, base/dir/link) 会把根复制进自己的子目录,
// 每递归一层都重新看到刚落盘的副本,直到 PATH_MAX 才以 IO 错误收场。
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
zip.start_file("repo-main/SKILL.md", opts).unwrap();
zip.write_all(b"---\nname: t\ndescription: d\n---\n")
.unwrap();
zip.add_directory("repo-main/dir/", opts).unwrap();
zip.add_symlink("repo-main/dir/link", "..", opts).unwrap();
zip.finish().unwrap();
}
let temp = tempdir().expect("tempdir");
let dest = temp.path().join("dest");
fs::create_dir_all(&dest).expect("create dest");
let archive = zip::ZipArchive::new(std::io::Cursor::new(buf)).expect("archive parses");
SkillService::extract_repo_archive(archive, &dest)
.expect("a self-containing symlink must be skipped, not blow up the extraction");
assert!(
dest.join("SKILL.md").is_file(),
"legitimate entries must still be extracted"
);
assert!(
!dest.join("dir").join("link").exists(),
"a symlink whose target contains the link itself must not be materialized"
);
}
#[test]
fn symlink_materialization_is_charged_to_the_archive_budget() {
// symlink 的物化走第二遍、与解压循环不同的代码路径。若它不计入同一个
// 预算,「一个大文件 + N 个指向它的 symlink」就能写下 N 倍字节而上限
// 全程显示合规。这里把预算预置到接近上限来验证物化确实在计费。
let temp = tempdir().expect("tempdir");
let base = temp.path().join("base");
fs::create_dir_all(base.join("payload")).expect("create payload dir");
fs::write(base.join("payload").join("big.bin"), vec![b'x'; 4096]).expect("write payload");
let symlinks = vec![(base.join("copy"), "payload".to_string())];
let mut total_bytes = MAX_ARCHIVE_TOTAL_BYTES - 1024;
let err = SkillService::resolve_symlinks_in_dir(&base, &symlinks, &mut total_bytes)
.expect_err("materializing 4 KiB with 1 KiB of budget left must fail");
assert!(
err.to_string().contains("ARCHIVE_TOO_LARGE"),
"unexpected error: {err}"
);
}
#[test]
fn symlink_guard_sees_through_unnormalized_link_paths() {
// `enclosed_name()` 不消解 `..`,所以 link_path 可能长成 `e/../d/self`。
// 守卫若拿它按字面跟规范化过的目标比组件,会在第一段(`e` vs `d`)就判定
// "不包含"——而这个位置物理上就在 `d` 里面,把 `d` 复制进去正是递归自复制。
let temp = tempdir().expect("tempdir");
let base = temp.path().join("base");
fs::create_dir_all(base.join("d").join("sub")).expect("create d");
fs::create_dir_all(base.join("e")).expect("create e");
let link_path = base.join("e").join("..").join("d").join("self");
let symlinks = vec![(link_path, ".".to_string())];
let mut total_bytes = 0u64;
SkillService::resolve_symlinks_in_dir(&base, &symlinks, &mut total_bytes)
.expect("a self-containing symlink must be skipped, not blow up the extraction");
assert!(
!base.join("d").join("self").exists(),
"a link that physically lives inside its own target must not be materialized"
);
}
/// 记录实际被消耗了多少字节的 reader。
///
/// 直接断言返回值是不够的:函数末尾本就有一道长度检查,把 `take` 的上限拆掉
/// 之后它照样返回 `None`,断言仍然通过——而炸弹的危害全在读取过程里,不在
/// 返回值。只有观测消耗量才能真正钉住"读取是有界的"。
struct CountingReader {
remaining: u64,
consumed: u64,
}
impl std::io::Read for CountingReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Ok(0);
}
let n = buf.len().min(self.remaining as usize);
buf[..n].fill(b'a');
self.remaining -= n as u64;
self.consumed += n as u64;
Ok(n)
}
}
#[test]
fn read_symlink_target_is_bounded_and_charged() {
// 一个打着 symlink 标志、解压流却极大的条目:zip 2.4.2 的 make_reader
// 不按声明的 uncompressed_size 截断,没有上限就会被整条读进内存。
let mut oversized = CountingReader {
remaining: 8 * 1024 * 1024,
consumed: 0,
};
let mut total_bytes = 0u64;
let target = SkillService::read_symlink_target(&mut oversized, &mut total_bytes)
.expect("an oversized target must be skipped, not raise");
assert!(
target.is_none(),
"a target longer than a path can plausibly be must be rejected"
);
assert_eq!(total_bytes, 0, "a rejected target must not be charged");
assert!(
oversized.consumed <= MAX_SYMLINK_TARGET_BYTES + 1,
"the read must stop at the cap instead of draining the stream, consumed {}",
oversized.consumed
);
// 正常目标照常读出来并计费
let mut normal = std::io::Cursor::new(b"../shared".to_vec());
let target = SkillService::read_symlink_target(&mut normal, &mut total_bytes)
.expect("a normal target must be read");
assert_eq!(target.as_deref(), Some("../shared"));
assert_eq!(total_bytes, 9);
}
#[test]
fn directory_materialization_is_charged_to_the_archive_budget() {
// 全是空目录的归档一个内容字节都不写。不给目录计费,第二遍的 symlink
// 解析就能让目录数按层数指数增长,而预算读数一直停在 0。
let temp = tempdir().expect("tempdir");
let src = temp.path().join("src");
fs::create_dir_all(src.join("a").join("b")).expect("create tree");
let mut total_bytes = MAX_ARCHIVE_TOTAL_BYTES - DIRECTORY_BUDGET_COST;
let err =
SkillService::copy_dir_within_budget(&src, &temp.path().join("dest"), &mut total_bytes)
.expect_err("materializing directories past the limit must fail");
assert!(
err.to_string().contains("ARCHIVE_TOO_LARGE"),
"unexpected error: {err}"
);
}
#[test]
fn create_dir_all_charges_every_directory_it_creates() {
// `create_dir_all` 一次能把缺失的父目录全建出来,所以一个条目名
// `a/a/…/a/f.txt` 可以隐式造出几百层。按调用次数计费会严重低估。
let temp = tempdir().expect("tempdir");
let deep = temp.path().join("a").join("b").join("c");
// 预算只够两层,建三层必须被拦下
let mut total_bytes = MAX_ARCHIVE_TOTAL_BYTES - 2 * DIRECTORY_BUDGET_COST;
let err = SkillService::create_dir_all_within_budget(&deep, &mut total_bytes)
.expect_err("creating more directories than the budget allows must fail");
assert!(
err.to_string().contains("ARCHIVE_TOO_LARGE"),
"unexpected error: {err}"
);
assert!(
!deep.exists(),
"nothing must be created once the budget is exceeded"
);
}
#[test]
fn extract_local_zip_leaves_no_partial_directory_when_it_fails() {
use std::io::Write;
use zip::write::SimpleFileOptions;
// scratch 只喂给这一次解压:并发测试的临时目录不会落进来,
// 所以"必须为空"的断言观测到的恰好就是这次解压的残留
let holder = tempdir().expect("tempdir");
let scratch = tempdir().expect("tempdir");
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
// `x` 先落成文件,再要求把它当目录用 —— create_dir_all 必然失败,
// 而这个失败发生在临时目录已经建好、且已经写进去东西之后
zip.start_file("x", opts).unwrap();
zip.write_all(b"i am a file").unwrap();
zip.start_file("x/y", opts).unwrap();
zip.write_all(b"and my parent is not a directory").unwrap();
zip.finish().unwrap();
}
let zip_path = holder.path().join("collide.zip");
fs::write(&zip_path, &buf).expect("write zip");
let result = SkillService::extract_local_zip_in(&zip_path, scratch.path());
assert!(
result.is_err(),
"the fixture must actually fail after the temp dir exists"
);
let leftovers: Vec<_> = fs::read_dir(scratch.path())
.expect("read scratch")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
assert!(
leftovers.is_empty(),
"a failed extraction must not leave a partial directory behind: {leftovers:?}"
);
}
#[test]
fn extract_local_zip_hands_back_a_guard_that_owns_the_tree() {
use std::io::Write;
use zip::write::SimpleFileOptions;
// 解压成功之后调用方还要走扫描 / 取 SSOT / 复制 / 写库 / 同步一长串 `?`。
// 之前返回裸 PathBuf,清理靠每条出口手写 remove_dir_all——实测漏了不止一条
// install_from_zip 的 copy_dir_recursive 与 save_skill、update_skill 的
// copy_dir_recursive、fetch_repo_skills 的 scan_dir_recursive 都会漏)。
let holder = tempdir().expect("tempdir");
let scratch = tempdir().expect("tempdir");
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
zip.start_file("s/SKILL.md", opts).unwrap();
zip.write_all(b"# skill").unwrap();
zip.finish().unwrap();
}
let zip_path = holder.path().join("ok.zip");
fs::write(&zip_path, &buf).expect("write zip");
let extracted = SkillService::extract_local_zip_in(&zip_path, scratch.path())
.expect("extract must succeed");
assert!(
extracted.path().join("s").join("SKILL.md").exists(),
"the fixture must actually extract something worth cleaning up"
);
// 模拟调用方在下游任意一个 `?` 上提前返回
drop(extracted);
let leftovers: Vec<_> = fs::read_dir(scratch.path())
.expect("read scratch")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
assert!(
leftovers.is_empty(),
"dropping the extraction result must take the whole tree with it: {leftovers:?}"
);
}
#[test]
fn extract_local_zip_rejects_dot_dot_entries() {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
zip.add_directory("d/", opts).unwrap();
zip.add_directory("e/", opts).unwrap();
// 净深度非负,enclosed_name() 放行;未消解的 `..` 会把它落到 d 里面
zip.start_file("e/../d/leaked.txt", opts).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
}
let temp = tempdir().expect("tempdir");
let zip_path = temp.path().join("dots.zip");
fs::write(&zip_path, &buf).expect("write zip");
let extracted = SkillService::extract_local_zip(&zip_path).expect("extract must not fail");
assert!(
!extracted.path().join("d").join("leaked.txt").exists(),
"an entry with an unresolved `..` must be skipped, not silently relocated"
);
}
#[test]
fn extract_local_zip_rejects_too_many_entries() {
// 本地 ZIP 走的是另一个解压器,条目上限曾只加在远端归档那条路径上。
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
for i in 0..(MAX_ARCHIVE_ENTRIES + 1) {
zip.start_file(format!("f{i}"), opts).unwrap();
zip.write_all(b"x").unwrap();
}
zip.finish().unwrap();
}
let temp = tempdir().expect("tempdir");
let zip_path = temp.path().join("bomb.zip");
fs::write(&zip_path, &buf).expect("write zip");
let err = SkillService::extract_local_zip(&zip_path)
.expect_err("entry count over the limit must be rejected for local ZIPs too");
assert!(
err.to_string().contains("ARCHIVE_TOO_MANY_ENTRIES"),
"unexpected error: {err}"
);
}
fn write_skill(dir: &Path, name: &str) {
fs::create_dir_all(dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
/// CC_SWITCH_TEST_HOME 隔离守卫(serial 测试间互斥由 #[serial] 保证,
/// 守卫只负责在测试结束后恢复原值)。
struct TestHomeGuard(Option<std::ffi::OsString>);
impl TestHomeGuard {
fn set(home: &Path) -> Self {
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", home);
guard
}
}
impl Drop for TestHomeGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
name: "poisoned".to_string(),
description: None,
directory: directory.to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::default(),
installed_at: 0,
content_hash: None,
updated_at: 0,
}
}
#[test]
fn require_valid_directory_accepts_single_segment_names_only() {
assert_eq!(
SkillService::require_valid_directory("my-skill").expect("valid name"),
"my-skill"
);
for bad in [
"..",
"../..",
"../../etc",
"a/b",
"a\\b",
"",
".hidden",
"C:\\evil",
"/etc",
] {
assert!(
SkillService::require_valid_directory(bad).is_err(),
"must reject: {bad:?}"
);
}
}
#[test]
#[serial_test::serial]
fn restore_from_backup_rejects_traversal_directory_in_metadata() {
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// 手工放置一个备份:meta.json 里的 directory 指向 SSOT 之外。
// SSOT 位于 {home}/.cc-switch/skills"../../pwned-restore" 若生效会写到 {home}/pwned-restore。
let backup_id = "20260727_120000_evil";
let backup_dir = SkillService::get_backup_dir()
.expect("backup dir")
.join(backup_id);
write_skill(&backup_dir.join("skill"), "evil");
let metadata = SkillBackupMetadata {
skill: poisoned_skill("owner/repo:evil", "../../pwned-restore"),
backup_created_at: 0,
source_path: "x".to_string(),
};
fs::write(
backup_dir.join("meta.json"),
serde_json::to_string_pretty(&metadata).expect("serialize metadata"),
)
.expect("write meta.json");
let db = std::sync::Arc::new(Database::memory().expect("memory db"));
let result = SkillService::restore_from_backup(&db, backup_id, &AppType::Claude);
assert!(
result.is_err(),
"restore must reject a traversal directory from meta.json"
);
assert!(
!temp.path().join("pwned-restore").exists(),
"restore must not write outside the SSOT dir"
);
}
#[test]
#[serial_test::serial]
fn remove_from_app_rejects_traversal_directory() {
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// 受害目录与 app skills 目录都先建好,保证未修复时代码真的能删到它:
// app_dir = {home}/.claude/skills"../../victim-remove" 解析为 {home}/victim-remove。
let victim = temp.path().join("victim-remove");
fs::create_dir_all(&victim).expect("create victim dir");
fs::create_dir_all(temp.path().join(".claude").join("skills")).expect("create app dir");
let result = SkillService::remove_from_app("../../victim-remove", &AppType::Claude);
assert!(result.is_err(), "remove_from_app must reject traversal");
assert!(victim.exists(), "victim directory must not be deleted");
}
#[test]
#[serial_test::serial]
fn uninstall_rejects_traversal_directory_from_db_row() {
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// 模拟同步导入灌进来的脏数据:directory 含路径穿越(save_skill 不校验,
// 与 import_sql_string_for_sync 的效果一致)。SSOT = {home}/.cc-switch/skills
// "../../victim-uninstall" 解析为 {home}/victim-uninstall。
let victim = temp.path().join("victim-uninstall");
fs::create_dir_all(&victim).expect("create victim dir");
let db = std::sync::Arc::new(Database::memory().expect("memory db"));
let skill = poisoned_skill("owner/repo:evil", "../../victim-uninstall");
db.save_skill(&skill).expect("seed poisoned row");
let result = SkillService::uninstall(&db, &skill.id);
// 危险的文件系统操作必须被跳过……
assert!(victim.exists(), "victim directory must not be deleted");
// ……但记录本身必须能删掉。db.delete_skill 全项目只有 uninstall 一处调用
// 且未暴露为命令,若这里返回 Err,脏行就永远无法从界面清除。
assert!(
result.is_ok(),
"uninstall must still succeed so the poisoned row can be removed: {result:?}"
);
assert!(
db.get_installed_skill(&skill.id)
.expect("query skill")
.is_none(),
"poisoned row must be deleted from the database"
);
}
#[test]
#[serial_test::serial]
fn migrate_storage_skips_bad_rows_without_moving_foreign_dirs() {
/// skill_storage_location 存在进程级全局 settings_store 里,migrate_storage
/// 成功后会改写它。#[serial] 只保证互斥、不负责还原,必须自己复位,
/// 否则后续测试的 get_ssot_dir() 会解析到另一个位置。
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let _location_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// migrate_storage 会 fs::rename / remove_dir_all,脏 directory 能把
// SSOT 之外的任意目录搬走或删掉。
let victim = temp.path().join("victim-migrate");
fs::create_dir_all(&victim).expect("create victim dir");
let db = std::sync::Arc::new(Database::memory().expect("memory db"));
let skill = poisoned_skill("owner/repo:evil", "../../victim-migrate");
db.save_skill(&skill).expect("seed poisoned row");
// 必须迁到与当前不同的位置,否则函数在 current == target 处直接短路返回
let result = SkillService::migrate_storage(&db, SkillStorageLocation::Unified)
.expect("migration must not abort");
assert!(victim.exists(), "foreign directory must not be moved away");
assert_eq!(
result.migrated_count, 0,
"poisoned row must not count as migrated"
);
assert!(
!result.errors.is_empty(),
"the skipped row must be reported through the errors channel"
);
}
#[test]
#[serial_test::serial]
fn uninstall_backup_source_rejects_traversal_directory() {
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// 备份源会被整目录复制进 skill-backups 并在界面列出 → 任意文件外泄面。
let secrets = temp.path().join("secrets");
fs::create_dir_all(&secrets).expect("create secrets dir");
fs::write(secrets.join("id_rsa"), b"PRIVATE").expect("write secret");
let skill = poisoned_skill("owner/repo:evil", "../../secrets");
let result = SkillService::resolve_uninstall_backup_source(&skill);
assert!(
result.is_err(),
"backup source must reject a traversal directory"
);
}
#[test]
#[serial_test::serial]
fn sync_to_app_skips_bad_rows_instead_of_aborting_the_whole_app() {
let temp = tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
let ssot_dir = SkillService::get_ssot_dir().expect("ssot dir");
write_skill(&ssot_dir.join("good-skill"), "good");
let db = std::sync::Arc::new(Database::memory().expect("memory db"));
// 一条脏行 + 一条正常行。脏行来自同步导入/存量数据,不得连累正常行——
// sync_to_app 在切换供应商时触发,整体中断会让所有 skill 一起失效。
//
// 名字刻意让脏行排前面:查询是 `ORDER BY name ASC`,脏行必须先被处理,
// 否则「未修复时会中断」这个前提不成立,测试就成了摆设。
let mut bad = poisoned_skill("owner/repo:bad", "../../escape-sync");
bad.name = "a-poisoned".to_string();
bad.apps = SkillApps::only(&AppType::Claude);
db.save_skill(&bad).expect("seed poisoned row");
let mut good = poisoned_skill("owner/repo:good", "good-skill");
good.name = "z-healthy".to_string();
good.apps = SkillApps::only(&AppType::Claude);
db.save_skill(&good).expect("seed good row");
SkillService::sync_to_app(&db, &AppType::Claude).expect("sync must not abort");
let app_dir = SkillService::get_app_skills_dir(&AppType::Claude).expect("app dir");
assert!(
app_dir.join("good-skill").exists(),
"the healthy skill must still be synced despite the poisoned row"
);
}
#[test]
// serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥,
// EnvGuard 只负责恢复不提供互斥。
#[serial_test::serial]
fn get_app_skills_dir_honors_test_home_override() {
// 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME
// 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效
// tests/skill_sync.rs 扫到 runner 真实用户目录)。
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
let temp = tempdir().expect("tempdir");
let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let dir =
SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir");
assert!(
dir.starts_with(temp.path()),
"skills dir must live under the overridden test home, got {}",
dir.display()
);
}
#[test]
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
let temp = tempdir().expect("tempdir");
write_skill(temp.path(), "Root Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "last30days-skill-cn")
.expect("root-level skill should resolve to the extracted repo root");
assert_eq!(resolved, temp.path());
}
#[test]
fn resolve_skill_source_dir_returns_direct_nested_directory_when_present() {
let temp = tempdir().expect("tempdir");
let nested = temp.path().join("skills").join("nested-skill");
write_skill(&nested, "Nested Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill")
.expect("nested skill should resolve from its relative source path");
assert_eq!(resolved, nested);
}
#[test]
fn resolve_skill_source_dir_falls_back_to_matching_install_name() {
let temp = tempdir().expect("tempdir");
let nested = temp.path().join("skills").join("nested-skill");
write_skill(&nested, "Nested Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "nested-skill")
.expect("install name should fall back to the matching discovered skill directory");
assert_eq!(resolved, nested);
}
#[test]
fn replace_dest_with_copy_rejects_empty_source_without_touching_existing_dest() {
let temp = tempdir().expect("tempdir");
let source = temp.path().join("source-skill");
let dest = temp.path().join("app-skills").join("source-skill");
fs::create_dir_all(&source).expect("create empty source");
write_skill(&dest, "Existing Skill");
let err = SkillService::replace_dest_with_copy(&source, &dest, "source-skill")
.expect_err("empty source should not replace existing app skill");
assert!(
err.to_string().contains("SKILL.md"),
"unexpected error: {err:#}"
);
assert!(
dest.join("SKILL.md").is_file(),
"existing destination skill should be preserved"
);
}
}