diff --git a/internal/resourcelibrary/export.go b/internal/resourcelibrary/export.go index 068518182..793d40c38 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -136,6 +136,10 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if !info.Mode().IsRegular() { return failedf("PACKAGE_INVALID", "validation", "special file is not allowed: %s", relative) } + // Skill 只有根目录 SKILL.md 是 Manifest;嵌套同名文件会让 Cloud/安装器产生歧义。 + if kind == KindSkill && relative != "SKILL.md" && strings.HasSuffix(relative, "/SKILL.md") { + return failedf("PACKAGE_INVALID", "validation", "managed Skill export contains a nested SKILL.md: %s", relative) + } if !allowedFile(kind, relative) { return failedf("PACKAGE_NOT_PORTABLE", "validation", "path is outside the portable allowlist: %s", relative) } diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 74f4ed9b8..58d78de59 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -57,6 +57,17 @@ func TestExportDirectory_拒绝符号链接特殊文件和白名单外路径(t * } } +func TestExportDirectory_Skill只允许根目录Manifest(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n") + writeFile(t, filepath.Join(root, "docs", "SKILL.md"), "---\nname: stale-copy\ndescription: Old manifest.\n---\n") + + _, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "skill.zip")) + if err == nil || !errorCode(err, "PACKAGE_INVALID") { + t.Fatalf("nested SKILL.md error = %v", err) + } +} + func TestExportDirectory_插件只导出可移植快照(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "plugin.json"), `{"name":"demo-plugin","version":"1.0.0","description":"Demo"}`) diff --git a/internal/resourcelibrary/upload.go b/internal/resourcelibrary/upload.go index d192c0cd8..403fd71db 100644 --- a/internal/resourcelibrary/upload.go +++ b/internal/resourcelibrary/upload.go @@ -57,7 +57,7 @@ func uploadWith(ctx context.Context, client nexusclient.Client, requestURI strin return failed("UPLOAD_REDIRECT_REJECTED", "validation", "package upload redirect was not followed") } if response.StatusCode < 200 || response.StatusCode >= 300 { - return failed("UPLOAD_FAILED", "validation", "package upload was not accepted") + return failed("UPLOAD_REJECTED", "validation", "package upload was rejected by Cloud") } return nil } diff --git a/internal/resourcelibrary/upload_test.go b/internal/resourcelibrary/upload_test.go index 301d4bb4f..0c7fad4af 100644 --- a/internal/resourcelibrary/upload_test.go +++ b/internal/resourcelibrary/upload_test.go @@ -135,6 +135,21 @@ func TestUploadAuthorizedPath_流式PUT且不跟随重定向(t *testing.T) { t.Fatalf("upload error leaked a token: %v", err) } + rejected := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "manifest mismatch", http.StatusBadRequest) + })) + defer rejected.Close() + rejectedFile, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer rejectedFile.Close() + _, rejectedClient := pinnedTestClient(t, rejected, "device-token") + err = uploadWith(context.Background(), rejectedClient, "/v1/nodes/library/transfer/ticket-ok", rejectedFile, int64(len(payload))) + if err == nil || !errorCode(err, "UPLOAD_REJECTED") { + t.Fatalf("rejected upload error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) cancel() var cancelledCalls atomic.Int32