farion1231/cc-switch · error · anyhow::Error
INVALID_SKILL_DIRECTORY
INVALID_SKILL_DIRECTORY
Error message
INVALID_SKILL_DIRECTORY
What it means
install() first sanitizes DiscoverableSkill.directory with sanitize_skill_source_path, which accepts multi-level relative paths (a/b/c) but rejects anything unsafe. A None return produces INVALID_SKILL_DIRECTORY with suggestion 'checkZipContent' and the offending directory in context. Unsafe here means: not a plain relative path — absolute paths, '..', components that escape the temp root, empty strings, or platform-illegal segments all fail. The value comes from the discovery feed (e.g. skills.sh), where it is only a skillId/relative dir, so a malformed catalog entry or a tampered value triggers it before any download.
Solutions
- Pass a safe relative directory like "python/pdf-tools" (no leading slash, no '..', forward slashes only).
- If the value came from a discovery listing, refresh it — the entry is bad upstream; report/skip that skill.
- Validate/normalize directory strings in your own code before calling install (strip leading './' and '/').
Example fix
// before
let skill = DiscoverableSkill { directory: "/abs/path/pdf-tools".into(), /* .. */ };
install(&db, &skill, &app).await?; // INVALID_SKILL_DIRECTORY
// after
let skill = DiscoverableSkill { directory: "python/pdf-tools".into(), /* .. */ };
install(&db, &skill, &app).await?; // proceeds to download Defensive patterns
Strategy: validation
Validate before calling
fn safe_rel_dir(dir: &str) -> Option<&str> {
if dir.is_empty() || dir.starts_with('/') { return None; }
let mut parts = dir.split('/');
if dir.split('/').any(|p| p == ".." || p == "." || p.is_empty()) { return None; }
Some(dir)
} Type guard
fn is_safe_relative_path(dir: &str) -> bool {
!dir.is_empty()
&& !dir.starts_with('/')
&& !dir.contains('\\')
&& dir.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
} Try / catch
match install(&db, &skill, &app).await {
Err(e) if e.to_string().contains("INVALID_SKILL_DIRECTORY") => {
// parse JSON payload; surface context.directory and 'checkZipContent' hint to user
}
other => other?,
} Prevention
- Normalize discovery feed entries: strip leading './' and '/' before calling install.
- Treat directory as untrusted input; never build it into filesystem paths without the relative-path check.
When it happens
Trigger: Calling install() with skill.directory = "/etc/skills/x" (absolute), "../escape", "" or a path whose segments fail sanitization; a discovery API returning a malformed directory field for that entry.
Common situations: Buggy aggregator data with absolute paths or ids containing path-traversal; hand-constructed DiscoverableSkill objects in scripts/tests using OS-specific separators; upstream catalog schema change adding prefixes like './' or Windows backslashes.
Related errors
AI-assisted analysis of farion1231/cc-switch@06082e189d (2026-08-20).
Data as JSON: /api/errors/4be75e3a6037f7e4.
Report an issue: GitHub.
Appendix: source
Thrown at src-tauri/src/services/skill.rs:776
}
/// 安装 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"),
))
})?;
// Fast path for an existing installation. The write guard makes the DBView on GitHub (pinned to 06082e189d)