siyuan-note/siyuan · error

unzip failed

Error message

unzip failed: %s

What it means

installFromZip writes the downloaded bytes to a temp zip and extracts it with gulu.Zip.Unzip; any extraction error is wrapped as 'unzip failed: <cause>'. gulu.Zip.Unzip also includes zip-slip protection, so an archive with entries escaping the target directory fails here too.

Solutions

  1. Verify the URL actually serves a zip file — open it in a browser; an HTML page means the URL is wrong or rate-limited
  2. Re-download and retry in case of a truncated/corrupted transfer
  3. Check the wrapped cause: a zip-slip message indicates a malicious or malformed archive — use a trusted source
  4. Check free disk space in the temp directory if the cause is a write error

Example fix

// before
InstallSkill("https://example.com/skill") // serves HTML, not a zip
// after
InstallSkill("https://example.com/skill.zip")
Defensive patterns

Strategy: validation

Validate before calling

const r = await fetch(downloadUrl)
const ct = r.headers.get("content-type") || ""
const head = new Uint8Array(await (await r.blob()).slice(0, 4).arrayBuffer())
const isZip = ct.includes("zip") || (head[0] === 0x50 && head[1] === 0x4b)
if (!isZip) throw new Error("URL does not serve a zip file")

Type guard

const looksLikeZip = (bytes) => bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04

Try / catch

try {
  await installSkill(zipUrl)
} catch (e) {
  if (String(e).startsWith("unzip failed:")) {
    if (String(e).includes("slip") || String(e).includes("path")) {
      reportUntrustedArchive(zipUrl)
    } else {
      showHint("URL did not return a valid zip; check it in a browser")
    }
  }
}

Prevention

When it happens

Trigger: The downloaded bytes are not a valid zip (HTML error page saved as zip, truncated download, wrong Content-Type guess), or the archive contains entries with unsafe paths blocked by the zip-slip guard, or disk I/O errors during extraction.

Common situations: A direct-link source that is not actually a zip (server returned an HTML login/rate-limit page); a corrupted or partial download; an archive crafted with ../ entry names; temp disk full.

Related errors


AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19). Data as JSON: /api/errors/1b2be984af192476. Report an issue: GitHub.

Appendix: source

Thrown at kernel/util/skill.go:742

// installFromZip 解压 zip 并安装其中的 skill
func installFromZip(data []byte) (*InstallSkillResult, error) {
	tmpRoot := filepath.Join(TempDir, "ai", "skill-install", gulu.Rand.String(7))
	if err := os.MkdirAll(tmpRoot, 0755); err != nil {
		return nil, err
	}
	defer os.RemoveAll(tmpRoot)

	zipPath := filepath.Join(tmpRoot, "src.zip")
	if err := os.WriteFile(zipPath, data, 0644); err != nil {
		return nil, err
	}
	unzipDir := filepath.Join(tmpRoot, "unzip")
	if err := os.MkdirAll(unzipDir, 0755); err != nil {
		return nil, err
	}
	// gulu.Zip.Unzip 已内置 zip-slip 路径穿越防护
	if err := gulu.Zip.Unzip(zipPath, unzipDir); err != nil {
		return nil, errors.New("unzip failed: " + err.Error())
	}

	skillDirs := findSkillDirs(unzipDir)
	if len(skillDirs) == 0 {
		return nil, errors.New("no SKILL.md found in the archive")
	}
	return installSkillDirs(skillDirs, unzipDir)
}

// findSkillDirs 在解压根下查找含 SKILL.md 的 skill 目录,返回相对 root 的路径。
// 递归下钻以兼容任意包裹层(codeload 会把仓库内容包在 <repo-name>/ 下),
// 但一旦某个目录被认定为 skill(直接含 SKILL.md)就停止下钻,避免误入 skill 内部的
// references/scripts 等子目录。识别的结构:
//   - SKILL.md 直接在 root(无包裹)
//   - <wrap>/SKILL.md(单层或多层包裹的单 skill)
//   - <wrap>/skills/<name>/SKILL.md(集合仓库,wrap 可有可无)
func findSkillDirs(root string) []string {
	if gulu.File.IsExist(filepath.Join(root, "SKILL.md")) {

View on GitHub (pinned to 9f775e8a12)