From e0f40a38782486e88d740f6b4cc4256fac64bad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=99I=20=C4=90=E1=BA=AEC=20KIM?= Date: Fri, 26 Jun 2026 05:59:35 +0700 Subject: [PATCH] fix(skills): normalize backslash in ZIP entry names for Windows compatibility (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(skills): normalize backslash in ZIP entry names for Windows compatibility ZIP archives created on Windows may use backslash (\) as path separator instead of forward slash (/). When extracted on Linux, this causes files like 'scripts\search.py' to be created as a single flat file instead of the proper 'scripts/search.py' directory structure. The ZIP specification (PKZIP APPNOTE) requires forward slashes, but many Windows tools produce non-conforming archives. This adds a strings.ReplaceAll backslash normalization step during skill ZIP extraction to handle this edge case. * test(skills): cover backslash zip extraction --------- Co-authored-by: buidackim <221586880+buidackim@users.noreply.github.com> Co-authored-by: Mỹ Ngân --- internal/http/skills_upload.go | 8 +++++- internal/http/skills_upload_test.go | 42 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index 4f69171f..ac6f2754 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -233,7 +233,13 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { continue } } - // Skip macOS/system artifacts + // Normalize Windows backslash separators in ZIP entries. + // ZIPs created on Windows may use `\` instead of `/` in paths, + // causing files like "scripts\search.py" to be extracted as a + // single flat file instead of scripts/search.py on Linux. + entryName = strings.ReplaceAll(entryName, "\\", "/") + // Skip macOS/system artifacts after separator normalization so + // entries like "__MACOSX\\._SKILL.md" are filtered too. if skills.IsSystemArtifact(entryName) { continue } diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index cac50477..3e28e482 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "os" "path/filepath" "reflect" "strings" @@ -1422,6 +1423,47 @@ func TestHandleUpload_ResponseIncludesIsNew(t *testing.T) { // --- Security Guard Tests --- +func TestHandleUpload_NormalizesBackslashZipEntryPaths(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + req := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": skillMarkdown("Windows ZIP Skill", "windows-zip-skill"), + "scripts\\search.py": "print('ok')\n", + }) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var resp struct { + ID string `json:"id"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + info, ok := skillStore.GetSkillByID(ctx, uuid.MustParse(resp.ID)) + if !ok { + t.Fatal("GetSkillByID returned !ok") + } + + wantPath := filepath.Join(info.BaseDir, "scripts", "search.py") + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("expected normalized path %s to exist: %v", wantPath, err) + } + flatPath := filepath.Join(info.BaseDir, `scripts\search.py`) + if _, err := os.Stat(flatPath); !os.IsNotExist(err) { + t.Fatalf("flat backslash path %s exists or stat failed unexpectedly: %v", flatPath, err) + } +} + func TestHandleUpload_MaliciousContent_CurlPipeBash_Rejected(t *testing.T) { handler, _, ctx, _ := newTestUploadHandler(t) stubUploadDepFns(t,