fix(skills): normalize backslash in ZIP entry names for Windows compatibility (#1103)

* 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 <goclaw-maintain@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-26 05:59:35 +07:00
1 parent c1ea0c0090
commit e0f40a3878
2 files changed
+49 -1

No files matched your search

+7 -1
View File
@@ -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
}
+42
View File
@@ -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,