return out
+def _real_entries(zf: zipfile.ZipFile):
+ """返回需处理的 (info, name, parts),跳过 __MACOSX 与 ._* 系统文件。"""
+ out = []
+ for info in zf.infolist():
+ name = info.filename
+ if name == "__MACOSX" or name.startswith("__MACOSX/"):
+ continue
+ parts = name.split("/")
+ if any(p.startswith("._") for p in parts):
+ continue
+ out.append((info, name, parts))
+ return out
+
+
+def _skill_name_from_zip(zf: zipfile.ZipFile):
+ """从 zip 内根目录的 SKILL.md frontmatter 提取 name(扁平结构也能用)。"""
+ for info, name, parts in _real_entries(zf):
+ if len(parts) == 1 and parts[0].lower() == "skill.md":
+ try:
+ txt = zf.read(info).decode("utf-8", "replace")
+ except Exception:
+ return None
+ nm, _ = _parse_skill_frontmatter(txt)
+ return nm
+ return None
+
+
def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path):
- """把 zip 内容(去掉单个顶层目录)解压到 dest,防 zip-slip。"""
+ """把 zip 内容解压到 dest,防 zip-slip。
+
+ - 跳过 __MACOSX / ._* 系统文件
+ - 若 zip 以单个顶层目录组织(my-skill/...),去掉该前缀
+ - 若为扁平结构(SKILL.md 等直接在根),原样解压
+ """
+ entries = _real_entries(zf)
+ if not entries:
+ raise ValueError("zip 为空或仅含系统文件")
+ tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
+ if len(tops) == 1:
+ top = tops.pop()
+ strip = True
+ else:
+ # 0 个或多个顶层目录(如技能内容直接压缩、无统一包装目录)→ 扁平解压
+ top = None
+ strip = False
dest.mkdir(parents=True, exist_ok=True)
- tops = set()
- for n in zf.namelist():
- p = n.split("/")[0]
- if p:
- tops.add(p)
- if len(tops) != 1:
- raise ValueError("zip 须以单个 skill 目录为顶层(如 my-skill/...)")
- top = tops.pop()
- for info in zf.infolist():
- rel = info.filename
- if rel == top or rel.startswith(top + "/"):
- rel = rel[len(top) + 1:]
- if not rel:
+ for info, name, p in entries:
+ rel = name[len(top) + 1:] if (strip and top) else name
+ rel = rel.rstrip("/")
+ if rel == "":
continue
target = (dest / rel).resolve()
if dest.resolve() not in target.parents and target != dest.resolve():
- raise ValueError("zip 含非法路径(zip-slip): " + info.filename)
+ raise ValueError("zip 含非法路径(zip-slip): " + name)
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
zf = _decode_zip_body(body)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
- tops = set(n.split("/")[0] for n in zf.namelist() if n.split("/")[0])
- name = body.get("name") or (tops.pop() if len(tops) == 1 else None)
- if not name:
- zf.close()
- return JSONResponse({"error": "无法确定 skill 名称:zip 需以单个目录为顶层,或提供 name 字段"}, status_code=400)
+ entries = _real_entries(zf)
+ tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
+ provided = body.get("name")
+ if provided:
+ name = provided
+ elif len(tops) == 1:
+ name = tops.pop()
+ else:
+ # 扁平结构(0 个或多个顶层目录,如技能内容直接压缩):优先用 SKILL.md frontmatter 的 name
+ fallback = (body.get("filename") or "skill").rsplit(".", 1)[0]
+ name = _skill_name_from_zip(zf) or fallback
if not _NAME_RE.match(name):
zf.close()
return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)