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,