mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
87b0e3fb85
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.
4456 lines
167 KiB
Rust
4456 lines
167 KiB
Rust
//! 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 是 camelCase,duration_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| {
|
||
// 使用完整 key(owner/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_dir(macOS 上 /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 URL,URL 解析会消解点段,
|
||
// 落点会从 /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"
|
||
);
|
||
}
|
||
}
|