{"record":{"id":"65035b2a0ac970a2","repo":"BigPizzaV3/CodexPlusPlus","slug":"skill-skills","errorCode":null,"errorMessage":"skill 在仓库中的路径不能为空","messagePattern":"skill 在仓库中的路径不能为空","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/codex-plus-core/src/skills.rs","lineNumber":776,"sourceCode":"    match std::fs::read_to_string(manifest) {\n        Ok(text) => parse_skill_frontmatter(&text, fallback_id),\n        Err(_) => (fallback_id.to_string(), String::new()),\n    }\n}\n\n/// 从仓库 zip 里只解出 `repo_path` 这一棵子树。\n///\n/// GitHub 的 zip 统一带一层 `repo-ref/` 前缀，`zip_entry_relative_path` 会剥掉它\n/// 并同时挡住路径逃逸。符号链接条目一律拒绝——与 codex 内置 skill-installer 的\n/// `_validate_skill` 一致，避免装进来的 skill 指到仓库外面。\npub fn extract_skill_subtree(\n    zip_bytes: &[u8],\n    repo_path: &str,\n    destination: &Path,\n) -> anyhow::Result<()> {\n    let repo_path = repo_path.trim_matches('/');\n    if repo_path.is_empty() {\n        anyhow::bail!(\"skill 在仓库中的路径不能为空\");\n    }\n    let prefix = format!(\"{repo_path}/\");\n    let mut archive =\n        zip::ZipArchive::new(Cursor::new(zip_bytes)).context(\"skill 仓库压缩包无法解析\")?;\n    let mut wrote_manifest = false;\n\n    for index in 0..archive.len() {\n        let mut file = archive\n            .by_index(index)\n            .with_context(|| format!(\"读取压缩包条目 {index} 失败\"))?;\n        if file.is_symlink() {\n            anyhow::bail!(\"skill 中不允许包含符号链接：{}\", file.name());\n        }\n        let Some(relative) = crate::plugin_marketplace::zip_entry_relative_path(file.name()) else {\n            continue;\n        };\n        // zip 内部的分隔符恒为 '/'，但上面拿回来的是 PathBuf，在 Windows 上\n        // to_str() 会渲染成 '\\'，跟用 '/' 拼出来的 prefix 永远匹配不上——结果就是","sourceCodeStart":758,"sourceCodeEnd":794,"githubUrl":"https://github.com/BigPizzaV3/CodexPlusPlus/blob/b1ed92e5e4a2d74095d4b8db5af43cef7acba9c6/crates/codex-plus-core/src/skills.rs#L758-L794","documentation":"extract_skill_subtree trims leading/trailing slashes from repo_path and bails when the result is empty. The parameter selects which subtree of the repo zip to extract; an empty value would mean extracting the whole repo (or nothing meaningful), so it is rejected before opening the archive.","triggerScenarios":"调用 extract_skill_subtree(zip_bytes, repo_path, destination) 时 repo_path 为空串、纯空格或仅由 '/' 组成（trim_matches('/') 后为空）。","commonSituations":"SkillRepo 的 subdir 字段为空却调用了按子目录安装的流程；从 GitHub trees API 响应取 path 时字段缺失得到空串；把根仓库（skill 在仓库根）的包直接塞进子树提取逻辑。","solutions":["确保传入 repo_path 为仓库内 skill 子目录路径，如 \"skills/my-skill\"","若 skill 位于仓库根目录，不应走 subtree 提取路径，改用根目录安装流程","提交前对 subdir 做非空校验（trim 后）。"],"exampleFix":"// before\nextract_skill_subtree(&zip_bytes, &repo.subdir, &dest)?; // subdir 可能为 \"\"\n// after\nlet rp = repo.subdir.trim().trim_matches('/');\nif rp.is_empty() {\n    anyhow::bail!(\"subdir 不能为空\");\n}\nextract_skill_subtree(&zip_bytes, rp, &dest)?;","handlingStrategy":"validation","validationCode":"fn valid_repo_path(subdir: &str) -> bool {\n    let t = subdir.trim().trim_matches('/');\n    !t.is_empty()\n}","typeGuard":null,"tryCatchPattern":"match extract_skill_subtree(&zip, repo_path, &dest) {\n    Err(e) if e.to_string().contains(\"路径不能为空\") => eprintln!(\"subdir 未配置，无法按子目录安装\"),\n    other => other?,\n}","preventionTips":["配置 SkillRepo 时确保 subdir 非空","skill 位于仓库根时改用根安装流程而非 subtree 提取","从 GitHub API 拿 path 时校验字段存在且非空"],"tags":["validation","zip","empty-field","skills"],"backgroundTag":"empty-required-field","analyzedSha":"b1ed92e5e4a2d74095d4b8db5af43cef7acba9c6","analyzedAt":"2026-09-19T23:35:21.129Z","contentChangedAt":"2026-09-19T23:35:21.129Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}