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

  1. Pass a safe relative directory like "python/pdf-tools" (no leading slash, no '..', forward slashes only).
  2. If the value came from a discovery listing, refresh it — the entry is bad upstream; report/skip that skill.
  3. 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

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 DB

View on GitHub (pinned to 06082e189d)